From 6396352c00352190c501194baf849eb10425a10e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 24 Jul 2026 10:18:40 -0400 Subject: [PATCH] refactor(core): clarify small model selection --- packages/core/src/catalog.ts | 106 +++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 48 deletions(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 9b8f916e70..54a6bf0cd5 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -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, 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[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] })