import { readJson, writeJsonAtomic } from "./util/persistence" import { isRecord } from "./util/record" import { Flock } from "@opencode/util/flock" import { watch } from "node:fs" import path from "node:path" export type ModelPreferenceModel = { providerID: string modelID: string } export type ModelPreference = { recent: ModelPreferenceModel[] favorite: ModelPreferenceModel[] variant: Record } export type ModelPreferenceDocument = Record & ModelPreference function models(value: unknown) { if (!Array.isArray(value)) return [] return value.flatMap((item): ModelPreferenceModel[] => { if (!isRecord(item)) return [] if (typeof item.providerID !== "string" || item.providerID.length === 0) return [] if (typeof item.modelID !== "string" || item.modelID.length === 0) return [] return [{ providerID: item.providerID, modelID: item.modelID }] }) } function variants(value: unknown) { if (!isRecord(value)) return {} return Object.fromEntries( Object.entries(value).flatMap(([key, item]) => { if (key.length === 0 || typeof item !== "string" || item.length === 0) return [] // Preserve explicit "default" so it can override an agent's configured variant. return [[key, item]] as const }), ) } export function normalizeModelVariant(value: string | undefined) { return value === "default" ? undefined : value } export function modelPreferenceKey(model: ModelPreferenceModel) { return `${model.providerID}/${model.modelID}` } export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) { const seen = new Set() return [model, ...recent] .filter((item) => { const key = modelPreferenceKey(item) if (seen.has(key)) return false seen.add(key) return true }) .slice(0, 10) .map((item) => ({ providerID: item.providerID, modelID: item.modelID })) } export function favoriteModels(model: ModelPreferenceModel, favorite: ModelPreferenceModel[], enabled: boolean) { const current = favorite.filter((item) => modelPreferenceKey(item) !== modelPreferenceKey(model)) return enabled ? [model, ...current] : current } export function cycleModelVariant(current: string | undefined, variants: string[]) { const named = variants.filter((variant) => variant !== "default") if (named.length === 0) return undefined const value = normalizeModelVariant(current) if (value === undefined) return named[0] const index = named.indexOf(value) if (index === -1 || index === named.length - 1) return undefined return named[index + 1] } export function decodeModelPreference(value: unknown): ModelPreferenceDocument { const root = isRecord(value) ? value : {} return { ...root, recent: models(root.recent), favorite: models(root.favorite), variant: variants(root.variant), } } function preference(value: ModelPreferenceDocument): ModelPreference { return { recent: value.recent, favorite: value.favorite, variant: value.variant, } } function patch(value: Partial) { return { ...(value.recent === undefined ? {} : { recent: models(value.recent) }), ...(value.favorite === undefined ? {} : { favorite: models(value.favorite) }), ...(value.variant === undefined ? {} : { variant: variants(value.variant) }), } } export function createModelPreferenceRepository(filePath: string) { let pending = Promise.resolve() let revision = 0 let watcher: ReturnType | undefined let reload: ReturnType | undefined const listeners = new Set<(value: ModelPreference) => void>() const read = () => readJson(filePath) .then(decodeModelPreference) .catch(() => decodeModelPreference(undefined)) function update(change: (current: ModelPreference) => Partial) { const result = pending.then(() => Flock.withLock( filePath, async () => { const current = await read() await writeJsonAtomic(filePath, { ...current, ...patch(change(preference(current))) }) }, { dir: path.join(path.dirname(filePath), "locks") }, ), ) pending = result.then( () => undefined, () => undefined, ) return result } function load() { return pending.then(read).then(preference) } async function refresh() { const current = ++revision const value = await load() if (current !== revision) return listeners.forEach((listener) => listener(value)) } return { load, addRecent(model: ModelPreferenceModel) { return update((current) => ({ recent: recentModels(model, current.recent) })) }, setFavorite(model: ModelPreferenceModel, enabled: boolean) { return update((current) => ({ favorite: favoriteModels(model, current.favorite, enabled) })) }, subscribe(listener: (value: ModelPreference) => void) { listeners.add(listener) void refresh() if (!watcher) { watcher = watch(path.dirname(filePath), (_event, filename) => { const changed = filename?.toString() const name = path.basename(filePath) if (changed !== undefined && changed !== name && !changed.startsWith(name + ".")) return clearTimeout(reload) reload = setTimeout(() => void refresh(), 50) }) watcher.on("error", () => { watcher?.close() watcher = undefined }) } return () => { listeners.delete(listener) if (listeners.size > 0) return clearTimeout(reload) watcher?.close() watcher = undefined } }, async resolveVariant(model: ModelPreferenceModel) { return normalizeModelVariant((await load()).variant[modelPreferenceKey(model)]) }, saveVariant(model: ModelPreferenceModel, value: string | undefined) { const key = modelPreferenceKey(model) const next = normalizeModelVariant(value) ?? "default" return update((current) => ({ variant: { ...current.variant, [key]: next } })) }, } }