chore(core): checkpoint model capability defaults

This commit is contained in:
Aiden Cline 2026-07-07 21:19:14 -05:00
parent 14a5529793
commit 0030028ebc
14 changed files with 380 additions and 28 deletions

View file

@ -49,7 +49,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,
temperature: Schema.Boolean,
tool_call: Schema.Boolean,

View file

@ -2,6 +2,7 @@ import { define } from "./internal"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ModelsDev } from "../models-dev"
import { ProviderV2 } from "../provider"
@ -97,11 +98,12 @@ function applyModel(
url: model.provider?.api,
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] }
draft.variants = []
draft.time.released = released(model.release_date)
draft.cost = input.cost ?? cost(model.cost)

View file

@ -10,13 +10,12 @@ import {
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, 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"
import { Location } from "../../location"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission"
import { ProviderV2 } from "../../provider"
import { QuestionV2 } from "../../question"
import { SystemContext } from "../../system-context/index"
import { SystemContextRegistry } from "../../system-context/registry"
@ -98,6 +97,7 @@ const layer = Layer.effect(
const agents = yield* AgentV2.Service
const tools = yield* ToolRegistry.Service
const models = yield* SessionRunnerModel.Service
const catalog = yield* Catalog.Service
const store = yield* SessionStore.Service
const location = yield* Location.Service
const systemContext = yield* SystemContextRegistry.Service
@ -196,11 +196,21 @@ const layer = Layer.effect(
}
const system =
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
const model = yield* models.resolve(session)
const resolved = yield* models.resolve(session)
const model = resolved.model
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, system.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(agent.info?.permissions)
const toolMaterialization =
isLastStep || !catalogModel.capabilities.tools
? undefined
: yield* tools.materialize(agent.info?.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
@ -208,7 +218,10 @@ const layer = Layer.effect(
system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
messages: [
...toLLMMessages(context, model, catalogModel.capabilities),
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
],
tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined,
})
@ -218,11 +231,7 @@ const layer = Layer.effect(
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent.id,
model: {
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
},
model: resolved.ref,
snapshot: startSnapshot,
})
const withPublication = Semaphore.makeUnsafe(1).withPermit
@ -420,6 +429,7 @@ export const node = makeLocationNode({
AgentV2.node,
ToolRegistry.node,
SessionRunnerModel.node,
Catalog.node,
SessionStore.node,
Location.node,
SystemContextRegistry.node,

View file

@ -71,8 +71,15 @@ export type Error =
| UnsupportedApiError
| Integration.AuthorizationError
export interface Resolved {
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
}
export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
@ -80,6 +87,16 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Test or embedding seam for supplying a model resolver directly. */
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 => ({
model,
ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
...(variant === undefined ? {} : { variant }),
}),
})
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
@ -205,11 +222,19 @@ export const locationLayer = Layer.effect(
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
return yield* resolve(
const model = yield* resolve(
session,
selected,
connection ? yield* integrations.connection.resolve(connection) : undefined,
)
return {
model,
ref: ModelV2.Ref.make({
id: selected.id,
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
}
}),
})
}),

View file

@ -7,6 +7,7 @@ import {
type Model,
type ProviderMetadata,
} from "@opencode-ai/llm"
import type { ModelV2 } from "../../model"
import { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt"
@ -18,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 toolInput = (tool: SessionMessage.AssistantTool) => {
if (tool.state.status !== "pending") return tool.state.input
try {
@ -112,7 +130,11 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
]
}
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
function toLLMMessage(
message: SessionMessage.Message,
model: Model,
capabilities?: ModelV2.Capabilities,
): Message[] {
switch (message.type) {
case "agent-switched":
case "model-switched":
@ -122,7 +144,10 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
Message.make({
id: message.id,
role: "user",
content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)],
content: [
{ type: "text", text: message.text },
...(message.files ?? []).map((file) => attachment(file, capabilities)),
],
metadata: {
...message.metadata,
...(message.agents?.length ? { agents: message.agents } : {}),
@ -167,5 +192,8 @@ ${message.recent}
}
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) =>
messages.flatMap((message) => toLLMMessage(message, model))
export const toLLMMessages = (
messages: readonly SessionMessage.Message[],
model: Model,
capabilities?: ModelV2.Capabilities,
) => messages.flatMap((message) => toLLMMessage(message, model, capabilities))

View file

@ -6,6 +6,7 @@ import { ConfigMCPV1 } from "./mcp"
import { ConfigPermissionV1 } from "./permission"
import { ConfigProviderV1 } from "./provider"
import { ConfigProviderOptionsV1 } from "./provider-options"
import { ModelV2 } from "../../model"
const keys = new Set([
"logLevel",
@ -211,8 +212,15 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st
: []),
]
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 {
family: info.family,

View file

@ -530,9 +530,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: {
@ -608,9 +616,16 @@ describe("Config", () => {
request: { body: { apiKey: "secret" } },
models: {
model: {
capabilities: { tools: false, input: ["text", "image"], output: ["text"] },
request: { body: { reasoningEffort: "high" } },
variants: [{ id: "fast", body: { 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({

View file

@ -239,6 +239,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.api.id).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([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])

View file

@ -1,5 +1,5 @@
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { Effect, Layer, Ref, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
@ -127,6 +127,21 @@ const initialState: MockState = {
}
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,
temperature: true,
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)

View file

@ -76,6 +76,26 @@ describe("ModelsDevPlugin", () => {
},
},
},
default: {
id: "default",
name: "Default",
release_date: "2026-01-01",
reasoning: false,
temperature: true,
tool_call: false,
limit: { context: 128_000, output: 8_192 },
},
explicit: {
id: "explicit",
name: "Explicit",
release_date: "2026-01-01",
attachment: true,
reasoning: false,
temperature: true,
tool_call: false,
modalities: { input: ["audio"], output: ["audio"] },
limit: { context: 128_000, output: 8_192 },
},
},
},
} satisfies Record<string, ModelsDev.Provider>),
@ -92,9 +112,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?.request.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",
providerID: "acme",
@ -159,6 +184,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(() => {

View file

@ -139,6 +139,145 @@ Recent work
])
})
test("defaults missing model capabilities to text and image input", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-local-image"),
type: "user",
text: "Inspect this image",
files: [
FileAttachment.make({
uri: "data:image/png;base64,AAECAw==",
mime: "image/png",
name: "image.png",
}),
FileAttachment.make({
uri: "data:application/pdf;base64,JVBERg==",
mime: "application/pdf",
name: "document.pdf",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,AAECAw==", 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 messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-unsupported-pdf"),
type: "user",
text: "Inspect these files",
files: [
FileAttachment.make({
uri: "data:image/png;base64,AAECAw==",
mime: "image/png",
name: "image.png",
}),
FileAttachment.make({
uri: "data:application/pdf;base64,JVBERg==",
mime: "application/pdf",
name: "document.pdf",
}),
],
time: { created },
}),
],
model,
{ 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: "data:application/pdf;base64,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({
uri: "data:image/png;base64,AAECAw==",
mime: "image/png",
name: "image.png",
}),
],
time: { created },
}),
],
model,
{ 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({
uri: "data:audio/mpeg;base64,AAECAw==",
mime: "audio/mpeg",
name: "audio.mp3",
}),
FileAttachment.make({
uri: "data:video/webm;base64,AAECAw==",
mime: "video/webm",
name: "video.webm",
}),
],
time: { created },
}),
],
model,
{ 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: "data:audio/mpeg;base64,AAECAw==", filename: "audio.mp3" },
{ type: "media", mediaType: "video/webm", data: "data:video/webm;base64,AAECAw==", filename: "video.webm" },
])
})
test("replays durable tool media into canonical tool messages without structured base64", () => {
const messages = toLLMMessages(
[

View file

@ -10,6 +10,7 @@ import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
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"
@ -32,6 +33,7 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry
import { SystemContext } from "@opencode-ai/core/system-context"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { ModelV2 } from "@opencode-ai/core/model"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
@ -67,7 +69,21 @@ const model = OpenAIChat.route
generation: { maxTokens: 20, temperature: 0 },
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(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 = AppNodeBuilder.build(SystemContextRegistry.node)
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
@ -76,6 +92,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[Catalog.node, catalog],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
@ -122,6 +139,7 @@ const it = testEffect(
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[SessionRunnerModel.node, models],
[Catalog.node, catalog],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],

View file

@ -38,6 +38,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
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"
@ -155,8 +156,33 @@ const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: ec
let modelResolveHook = Effect.void
let currentModel = model
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : currentModel)),
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(
session.model?.id === "replacement" ? replacementModel : currentModel,
session.model?.variant,
),
),
),
)
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 = SystemContext.Key.make("test/context")
let systemBaseline = "Initial context"
let systemRemoved = false
@ -229,6 +255,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[Catalog.node, catalog],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
@ -278,6 +305,7 @@ const it = testEffect(
[LayerNodePlatform.llmClient, client],
[PermissionV2.node, permission],
[SessionRunnerModel.node, models],
[Catalog.node, catalog],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
@ -318,6 +346,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
@ -655,6 +684,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: 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* () {
yield* setup

View file

@ -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({
@ -93,7 +110,7 @@ export const Info = Schema.Struct({
providerID,
name: modelID,
api: { id: modelID, type: "native", settings: {} },
capabilities: { tools: false, input: [], output: [] },
capabilities: Capabilities.defaults(),
request: { headers: {}, body: {} },
variants: [],
time: { released: 0 },