chore(core): checkpoint model capability defaults

This commit is contained in:
Aiden Cline 2026-07-07 21:19:14 -05:00
commit 77fce8b24c
14 changed files with 301 additions and 25 deletions

View file

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

View file

@ -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) => ({

View file

@ -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,

View file

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

View file

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

View file

@ -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,