opencode/packages/core/src/model.ts

364 lines
15 KiB
TypeScript

import { Model } from "@opencode/schema/model"
import { Provider } from "./provider.js"
import type { DeepMutable } from "./schema.js"
import { Context, Effect, Layer, Stream } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Bus } from "./bus.js"
import { State } from "./state.js"
import { Location } from "./location.js"
import { freeze } from "immer"
import { AISDKNative } from "./aisdk-native.js"
export const ID = Model.ID
export type ID = typeof ID.Type
export const VariantID = Model.VariantID
export type VariantID = typeof VariantID.Type
// Grouping of models, eg claude opus, claude sonnet
export const Family = Model.Family
export type Family = Model.Family
export const ReasoningField = Model.ReasoningField
export type ReasoningField = Model.ReasoningField
export const Compatibility = Model.Compatibility
export type Compatibility = Model.Compatibility
export const Capabilities = Model.Capabilities
export type Capabilities = Model.Capabilities
/** Merges partial config capabilities onto a base model's capabilities, defaulting unset fields. */
export const mergeCapabilities = (config: Partial<Capabilities>, base: Capabilities | undefined) => {
const fallback = base ?? Capabilities.default()
return {
tools: config.tools ?? fallback.tools,
input: [...(config.input ?? fallback.input)],
output: [...(config.output ?? fallback.output)],
temperature: config.temperature ?? base?.temperature,
}
}
export const Cost = Model.Cost
export const Type = Model.Type
export type Type = Model.Type
export const Ref = Model.Ref
export type Ref = typeof Ref.Type
export const Info = Model.Info
export type Info = Model.Info
/** Effective provider and model settings used only while constructing a runtime model. */
export type RuntimeInfo = Omit<Info, "settings"> & { readonly settings?: Provider.Settings }
export type MutableInfo = DeepMutable<Info>
export { Event } from "@opencode/schema/model"
export interface Editor {
/** Includes disabled candidates so later transforms can re-enable them. */
readonly list: (providerID?: Provider.ID) => readonly MutableInfo[]
readonly get: (providerID: Provider.ID, modelID: ID) => MutableInfo | undefined
readonly update: (providerID: Provider.ID, modelID: ID, update: (model: MutableInfo) => void) => void
readonly remove: (providerID: Provider.ID, modelID: ID) => void
readonly default: {
readonly get: () => { providerID: Provider.ID; modelID: ID } | undefined
readonly set: (providerID: Provider.ID, modelID: ID) => void
}
/** Coherent, read-only provider inputs, including definitions from inactive providers. */
readonly provider: {
readonly list: () => readonly Provider.Definition[]
readonly get: (providerID: Provider.ID) => Provider.Definition | undefined
}
}
export interface Interface extends State.Transformable<Editor> {
readonly get: (providerID: Provider.ID, modelID: ID) => Effect.Effect<Info | undefined>
readonly all: () => Effect.Effect<readonly Info[]>
/** Enabled models of the given types, chat when none are given. Only chat models are offered for sessions. */
readonly available: (types?: readonly Model.Type[]) => Effect.Effect<readonly Info[]>
readonly default: () => Effect.Effect<Info | undefined>
readonly small: (providerID: Provider.ID) => Effect.Effect<Info | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Model") {}
type Data = {
models: Map<Provider.ID, ReadonlyMap<ID, Info>>
defaultModel?: { providerID: Provider.ID; modelID: ID }
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const providers = yield* Provider.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const disableFree = ["1", "true"].includes(process.env.OPENCODE_DISABLE_FREE_MODELS?.toLowerCase() ?? "")
let input: Provider.Snapshot | undefined
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
name: "model",
initial: () => ({
models: new Map((input?.available ?? []).map((record) => [record.provider.id, record.models])),
}),
editor: (data) => {
// Definitions are shared across Locations; a provider's map and a model are copied before their first edit.
const owned = new WeakSet<ReadonlyMap<ID, Info>>()
const drafts = new WeakSet<Info>()
const writable = (providerID: Provider.ID) => {
const current = data.models.get(providerID)
if (!current) return undefined
if (owned.has(current)) return current as Map<ID, Info>
const copy = new Map(current)
owned.add(copy)
data.models.set(providerID, copy)
return copy
}
const draft = (providerID: Provider.ID, modelID: ID) => {
const models = writable(providerID)
if (!models) return undefined
const current = models.get(modelID)
if (!current) return undefined
if (drafts.has(current)) return current as MutableInfo
const copy = structuredClone(current) as MutableInfo
drafts.add(copy)
models.set(modelID, copy)
return copy
}
return {
list: (providerID) => {
const ids = providerID === undefined ? Array.from(data.models.keys()) : [providerID]
return ids.flatMap((id) =>
Array.from(data.models.get(id)?.keys() ?? []).flatMap((modelID) => draft(id, modelID) ?? []),
)
},
get: draft,
update: (providerID, modelID, update) => {
// Model edits cannot create/enable a provider or bypass its availability decision.
const models = writable(providerID)
if (!models) return
const model = draft(providerID, modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
update(model)
model.id = modelID
model.providerID = providerID
const provider = input?.records.get(providerID)?.provider
AISDKNative.rewrite(model, {
specifier: model.package ?? provider?.package,
providerID,
canonical: model.canonical ?? provider?.canonical,
modelID: model.modelID ?? modelID,
})
drafts.add(model)
models.set(modelID, model)
},
remove: (providerID, modelID) => {
writable(providerID)?.delete(modelID)
},
default: {
get: () => data.defaultModel,
set: (providerID, modelID) => {
data.defaultModel = { providerID, modelID }
},
},
provider: {
list: () => Array.from(input?.records.values() ?? []),
get: (providerID) => input?.records.get(providerID),
},
}
},
// read() also refreshes dependencies changed inside a State.batch before notification.
notify: () => notify,
})
yield* Effect.addFinalizer(() => State.shutdown(state.reload()))
let cached:
| {
data: Data
all: readonly Info[]
available: readonly Info[]
other: ReadonlyMap<Model.Type, readonly Info[]>
byProvider: ReadonlyMap<Provider.ID, ReadonlyMap<ID, Info>>
}
| undefined
// An unedited model keeps its shared definition object across rebuilds, so its merged output is reusable.
const merged = new WeakMap<Info, { provider: Provider.Info | undefined; model: Info }>()
const read = Effect.fn("Model.snapshot")(function* () {
while (true) {
const current = yield* providers.snapshot()
if (input !== current) {
input = current
state.invalidate()
}
const data = state.get()
if (cached?.data === data) return cached
// A failed model transform can remove its plugin's provider/integration registrations.
// Re-materialize from the surviving inputs, not from the failed candidate's seed.
if (current !== (yield* providers.snapshot())) continue
const byProvider = new Map(
Array.from(data.models, ([providerID, models]) => {
const provider = current.records.get(providerID)?.provider
return [
providerID,
new Map(
Array.from(models, ([id, model]) => {
const reusable = merged.get(model)
if (reusable && reusable.provider === provider) return [id, reusable.model]
const value = {
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
compatibility:
provider?.compatibility === undefined
? model.compatibility
: { ...provider.compatibility, ...model.compatibility },
settings: Provider.mergeOverlay(
Provider.modelSettings(provider?.settings),
Provider.modelSettings(model.settings),
),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
} satisfies Info
merged.set(model, { provider, model: value })
return [id, value]
}),
),
]
}),
)
if (disableFree)
for (const providerID of [Provider.ID.opencode, Provider.ID.make("opencode-go")])
byProvider.get(providerID)?.forEach((model, id, models) => {
if (!model.cost.some((cost) => cost.input > 0)) models.delete(id)
})
// Non-chat models (image, decision…) only show up when listed by type, so nothing that
// picks or resolves a session model can reach them.
const other = new Map<Model.Type, Info[]>()
byProvider.forEach((models) =>
models.forEach((model, id) => {
if ((model.type ?? "chat") === "chat") return
models.delete(id)
if (model.enabled) other.set(model.type!, [...(other.get(model.type!) ?? []), model])
}),
)
const all = Array.from(byProvider.values())
.flatMap((models) => Array.from(models.values()))
.sort((left, right) => right.time.released - left.time.released)
other.forEach((models) => models.sort((left, right) => right.time.released - left.time.released))
cached = freeze(
{ data, all, available: preferFree(all.filter((model) => model.enabled)), other, byProvider },
true,
)
return cached
}
})
const prepare = providers.snapshot().pipe(
Effect.tap((current) =>
Effect.sync(() => {
if (input === current) return
input = current
state.invalidate()
}),
),
)
const reload = () => prepare.pipe(Effect.andThen(state.reload()))
let published: typeof cached
const notify: Effect.Effect<void> = Effect.gen(function* () {
const value = yield* read()
if (value === published) return
published = value
yield* bus.publish(
Model.Event.Updated,
{},
{
location: { directory: location.directory, workspaceID: location.workspaceID },
},
)
})
yield* bus.subscribe(Provider.Event.Updated).pipe(
Stream.runForEach(() => notify),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({
transform: (update) => prepare.pipe(Effect.andThen(state.transform(update))),
reload,
get: Effect.fn("Model.get")((providerID, modelID) =>
read().pipe(Effect.map((value) => value.byProvider.get(providerID)?.get(modelID))),
),
all: Effect.fn("Model.all")(() => read().pipe(Effect.map((value) => value.all))),
available: Effect.fn("Model.available")((types?: readonly Model.Type[]) =>
read().pipe(
Effect.map((value) =>
types === undefined || types.length === 0
? value.available
: [...new Set(types)].flatMap((type) =>
type === "chat" ? value.available : (value.other.get(type) ?? []),
),
),
),
),
default: Effect.fn("Model.default")(function* () {
const value = yield* read()
const requested = value.data.defaultModel
const model = requested && value.byProvider.get(requested.providerID)?.get(requested.modelID)
return model?.enabled ? model : value.available.find(supportsText)
}),
small: Effect.fn("Model.small")(function* (providerID) {
const value = yield* read()
const models = value.available.filter(
(model) =>
model.providerID === providerID &&
model.status === "active" &&
model.capabilities.input.some((item) => item.startsWith("text")) &&
model.capabilities.output.some((item) => item.startsWith("text")),
)
return ["gpt-luna", "gemini-flash-lite", "gemini-flash", "claude-haiku"].flatMap(
(family) => models.find((model) => model.family === family) ?? [],
)[0]
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Provider.node, Bus.node, Location.node] })
// Signed out, only free OpenCode models are enabled. Clients default to the first available model, so keep
// these free models ahead of each new free launch instead of letting release order pick the default.
const PREFERRED_FREE = [
"big-pickle",
"deepseek-v4.1-flash-free",
"glm-5.3-flash-free",
"muse-spark-1.3-contributor-free",
]
function preferFree(models: Info[]) {
const freeOnly = models.every(
(model) => model.providerID === Provider.ID.opencode && !model.cost.some((cost) => cost.input > 0),
)
if (!freeOnly) return models
const picks = PREFERRED_FREE.flatMap((id) => models.find((model) => model.id === id) ?? [])
return [...picks, ...models.filter((model) => !picks.includes(model))]
}
export function supportsText(model: Pick<Info, "capabilities">) {
return (
(model.capabilities.input.length === 0 || model.capabilities.input.some((item) => item.startsWith("text"))) &&
(model.capabilities.output.length === 0 || model.capabilities.output.some((item) => item.startsWith("text")))
)
}
export function compatibility(input: unknown): Compatibility | undefined {
if (typeof input === "string") return { reasoningField: input }
if (typeof input !== "object" || input === null || Array.isArray(input) || !("field" in input)) return undefined
return typeof input.field === "string" ? { reasoningField: input.field } : undefined
}
export function parse(input: string): { providerID: Provider.ID; modelID: ID } {
const index = input.indexOf("/")
return {
providerID: Provider.ID.make(index === -1 ? input : input.slice(0, index)),
modelID: ID.make(index === -1 ? "" : input.slice(index + 1)),
}
}
export * as Model from "./model.js"