From c5dde0cabb79e9661f321e613a9e7db316536472 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 12 Jul 2026 20:07:06 -0500 Subject: [PATCH] fix(core): align small model selection with v1 families Replace the opencode gpt-5-nano hardcode and cost/name heuristic with V1-style family priority: newest model in gpt-nano / gemini-flash / claude-haiku (with opencode and copilot family order, Bedrock region prefix handling, and Azure skip). No small_model config. --- packages/core/src/catalog.ts | 71 ++++---- packages/core/test/catalog.test.ts | 155 ++++++++++++++---- .../test/plugin/provider-opencode.test.ts | 20 ++- packages/core/test/session-title.test.ts | 26 +-- 4 files changed, 174 insertions(+), 98 deletions(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index c12793b358..a898fd618f 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 "./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" @@ -217,48 +217,41 @@ const layer = Layer.effect( 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 priority = providerID.startsWith("opencode") + ? ["gpt-nano"] + : providerID.startsWith("github-copilot") + ? ["gpt-mini", ...SMALL_MODEL_FAMILY_PRIORITY] + : SMALL_MODEL_FAMILY_PRIORITY - const candidates = pipe( + const models = 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), + Array.filter((model) => model.enabled && model.status === "active"), + Array.sortWith((model) => model.id, Order.flip(Order.String)), + Array.sortWith((model) => model.time.released, Order.flip(Order.Number)), ) - 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, - ) + for (const family of priority) { + const candidates = models.filter((model) => model.family === family) + if (providerID === ProviderV2.ID.amazonBedrock) { + const crossRegionPrefixes = ["global.", "us.", "eu."] + const globalMatch = candidates.find((model) => model.id.startsWith("global.")) + if (globalMatch) return projectModel(globalMatch, provider) + + const region = typeof provider.settings?.region === "string" ? provider.settings.region : undefined + if (region) { + const regionPrefix = region.split("-")[0] + if (regionPrefix === "us" || regionPrefix === "eu") { + const regionalMatch = candidates.find((model) => model.id.startsWith(`${regionPrefix}.`)) + if (regionalMatch) return projectModel(regionalMatch, provider) + } + } + + const unprefixed = candidates.find((model) => !crossRegionPrefixes.some((prefix) => model.id.startsWith(prefix))) + if (unprefixed) return projectModel(unprefixed, provider) + continue + } + if (candidates[0]) return projectModel(candidates[0], provider) } - - return Option.getOrUndefined( - pipe( - candidates, - Array.filter((item) => item.small), - (items) => (items.length > 0 ? pick(items) : pick(candidates)), - ), - ) }), }, } @@ -267,6 +260,6 @@ const layer = Layer.effect( }), ) -const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ +const SMALL_MODEL_FAMILY_PRIORITY = ["gemini-flash", "gpt-nano", "claude-haiku"] export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] }) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index ad667f288c..1e65ba6463 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -290,45 +290,144 @@ describe("CatalogV2", () => { }), ) - it.effect("small model prefers small keyword candidates before cost scoring", () => + it.effect("small model selects the latest model in the preferred family", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.make("test") yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) - catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = [ - { - input: Money.USDPerMillionTokens.make(1), - output: Money.USDPerMillionTokens.make(1), - cache: { - read: Money.USDPerMillionTokens.zero, - write: Money.USDPerMillionTokens.zero, - }, - }, - ] + catalog.model.update(providerID, ModelV2.ID.make("old-flash"), (model) => { + model.family = ModelV2.Family.make("gemini-flash") + model.time.released = Date.now() - 1000 + }) + catalog.model.update(providerID, ModelV2.ID.make("new-flash"), (model) => { + model.family = ModelV2.Family.make("gemini-flash") model.time.released = Date.now() }) - catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = [ - { - input: Money.USDPerMillionTokens.make(10), - output: Money.USDPerMillionTokens.make(10), - cache: { - read: Money.USDPerMillionTokens.zero, - write: Money.USDPerMillionTokens.zero, - }, - }, - ] + catalog.model.update(providerID, ModelV2.ID.make("newer-haiku"), (model) => { + model.family = ModelV2.Family.make("claude-haiku") + model.time.released = Date.now() + 1000 + }) + }) + + expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("new-flash")) + }), + ) + + it.effect("small model matches exact model families", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("glm-flash"), (model) => { + model.family = ModelV2.Family.make("glm-flash") + model.time.released = Date.now() + 1000 + }) + catalog.model.update(providerID, ModelV2.ID.make("claude-haiku"), (model) => { + model.family = ModelV2.Family.make("claude-haiku") model.time.released = Date.now() }) }) - expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini") + expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("claude-haiku")) + }), + ) + + it.effect("small model ignores model IDs without family metadata", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("test") + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { + model.time.released = Date.now() + }) + }) + + expect(yield* catalog.model.small(providerID)).toBeUndefined() + }), + ) + + it.effect("small model prefers gpt-nano family for opencode providers", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.opencode + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("old-nano"), (model) => { + model.family = ModelV2.Family.make("gpt-nano") + model.time.released = Date.now() - 1000 + }) + catalog.model.update(providerID, ModelV2.ID.make("new-nano"), (model) => { + model.family = ModelV2.Family.make("gpt-nano") + model.time.released = Date.now() + }) + catalog.model.update(providerID, ModelV2.ID.make("flash"), (model) => { + model.family = ModelV2.Family.make("gemini-flash") + model.time.released = Date.now() + 1000 + }) + }) + + expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("new-nano")) + }), + ) + + it.effect("small model prefers gpt-mini family for github-copilot providers", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.githubCopilot + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("flash"), (model) => { + model.family = ModelV2.Family.make("gemini-flash") + model.time.released = Date.now() + }) + catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => { + model.family = ModelV2.Family.make("gpt-mini") + model.time.released = Date.now() - 1000 + }) + }) + + expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("mini")) + }), + ) + + it.effect("small model prefers global bedrock deployments before regional ones", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.amazonBedrock + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, (provider) => { + provider.settings = { region: "us-west-2" } + }) + catalog.model.update(providerID, ModelV2.ID.make("us.claude-haiku"), (model) => { + model.family = ModelV2.Family.make("claude-haiku") + model.time.released = Date.now() + }) + catalog.model.update(providerID, ModelV2.ID.make("global.claude-haiku"), (model) => { + model.family = ModelV2.Family.make("claude-haiku") + model.time.released = Date.now() - 1000 + }) + }) + + expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("global.claude-haiku")) + }), + ) + + it.effect("small model skips inferred models for Azure providers", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + for (const providerID of [ProviderV2.ID.azure, ProviderV2.ID.make("azure-cognitive-services")]) { + yield* catalog.transform((catalog) => { + catalog.provider.update(providerID, () => {}) + catalog.model.update(providerID, ModelV2.ID.make("small"), (model) => { + model.family = ModelV2.Family.make("gemini-flash") + model.time.released = Date.now() + }) + }) + expect(yield* catalog.model.small(providerID)).toBeUndefined() + } }), ) }) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index dfb0e82d0a..29a38261b0 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -481,30 +481,38 @@ describe("OpencodePlugin", () => { ), ) - it.effect("prefers gpt-5-nano as the opencode small model", () => + it.effect("prefers the newest gpt-nano family model for opencode", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const providerID = ProviderV2.ID.opencode yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) - catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { + catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { + model.family = ModelV2.Family.make("gpt-nano") + model.capabilities.input = ["text"] + model.capabilities.output = ["text"] + model.cost = [...cost(10, 10)] + model.time.released = Date.now() - 1000 + }) + catalog.model.update(providerID, ModelV2.ID.make("gpt-5.4-nano"), (model) => { + model.family = ModelV2.Family.make("gpt-nano") model.capabilities.input = ["text"] model.capabilities.output = ["text"] model.cost = [...cost(1, 1)] model.time.released = Date.now() }) - catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { + catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] - model.cost = [...cost(10, 10)] - model.time.released = Date.now() + model.cost = [...cost(1, 1)] + model.time.released = Date.now() + 1000 }) }) const selected = yield* catalog.model.small(providerID) - expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano")) + expect(selected?.id).toBe(ModelV2.ID.make("gpt-5.4-nano")) }), ) }) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 76f52e60ea..d1906bf20c 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -21,7 +21,6 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" -import { Money } from "@opencode-ai/schema/money" import { Effect, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" @@ -146,33 +145,10 @@ const seedSmallModel = () => yield* catalog.transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("main"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = [ - { - input: Money.USDPerMillionTokens.make(50), - output: Money.USDPerMillionTokens.make(50), - cache: { - read: Money.USDPerMillionTokens.zero, - write: Money.USDPerMillionTokens.zero, - }, - }, - ] model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => { - model.capabilities.input = ["text"] - model.capabilities.output = ["text"] - model.cost = [ - { - input: Money.USDPerMillionTokens.make(1), - output: Money.USDPerMillionTokens.make(1), - cache: { - read: Money.USDPerMillionTokens.zero, - write: Money.USDPerMillionTokens.zero, - }, - }, - ] + model.family = ModelV2.Family.make("gpt-nano") model.time.released = Date.now() }) })