Compare commits

...
Author SHA1 Message Date
Kit Langton 6396352c00 refactor(core): clarify small model selection 2026-07-24 10:18:40 -04:00
+58 -48
View File
@@ -1,7 +1,7 @@
export * as Catalog from "./catalog"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
@@ -208,57 +208,24 @@ const layer = Layer.effect(
}),
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return
const provider = record.provider
const catalog = state.get().providers.get(providerID)
if (!catalog) return
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) {
return
}
const cannotDiscoverAvailableModels =
providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")
if (cannotDiscoverAvailableModels) return
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
}
const designated =
providerID === ProviderV2.ID.opencode ? catalog.models.get(ModelV2.ID.make("gpt-5-nano")) : undefined
if (designated && isActive(designated)) return projectModel(designated, catalog.provider)
const candidates = pipe(
Array.fromIterable(record.models.values()),
Array.filter(
(model) =>
model.providerID === providerID &&
model.enabled &&
model.status === "active" &&
model.capabilities.input.some((item) => item.startsWith("text")) &&
model.capabilities.output.some((item) => item.startsWith("text")),
),
Array.map((model) => ({
model,
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
})),
Array.filter((item) => item.cost > 0 && item.age <= 18),
)
const pick = (items: typeof candidates) => {
const maxCost = Math.max(...items.map((item) => item.cost), 0.01)
const maxAge = Math.max(...items.map((item) => item.age), 0.01)
return pipe(
items,
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
Array.map((item) => projectModel(item.model, provider)),
Array.head,
)
}
return Option.getOrUndefined(
pipe(
candidates,
Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
),
)
const candidates = modelCandidates(catalog.models.values(), providerID)
const small = candidates.filter((candidate) => candidate.looksSmall)
const pool = small.length > 0 ? small : candidates
const selected = bestValue(pool)
if (!selected) return
return projectModel(selected.model, catalog.provider)
}),
},
}
@@ -268,5 +235,48 @@ const layer = Layer.effect(
)
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
const MONTH_MS = 1000 * 60 * 60 * 24 * 30
const isActive = (model: ModelV2.Info) => model.enabled && model.status === "active"
const modelCandidates = (models: Iterable<ModelV2.Info>, providerID: ProviderV2.ID) => {
const now = Date.now()
return Array.fromIterable(models).flatMap((model) => {
if (
model.providerID !== providerID ||
!isActive(model) ||
!model.capabilities.input.some((item) => item.startsWith("text")) ||
!model.capabilities.output.some((item) => item.startsWith("text"))
)
return []
const cost = model.cost[0] ? model.cost[0].input + model.cost[0].output : 999
const age = (now - model.time.released) / MONTH_MS
if (cost <= 0 || age > 18) return []
return [
{
model,
cost,
age,
looksSmall: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
},
]
})
}
type Candidate = ReturnType<typeof modelCandidates>[number]
const bestValue = (candidates: readonly Candidate[]) => {
if (!Array.isReadonlyArrayNonEmpty(candidates)) return
const maxCost = Math.max(...candidates.map((candidate) => candidate.cost), 0.01)
const maxAge = Math.max(...candidates.map((candidate) => candidate.age), 0.01)
return Array.min(
candidates,
Order.mapInput(
Order.Number,
(candidate: Candidate) => (candidate.cost / maxCost) * 0.8 + (candidate.age / maxAge) * 0.2,
),
)
}
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })