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.
This commit is contained in:
parent
83c6e0f8ea
commit
c5dde0cabb
4 changed files with 174 additions and 98 deletions
|
|
@ -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] })
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue