mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-06 17:59:05 +00:00
364 lines
15 KiB
TypeScript
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"
|