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:
Aiden Cline 2026-07-12 20:07:06 -05:00
commit c5dde0cabb
4 changed files with 174 additions and 98 deletions

View file

@ -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()
}
}),
)
})

View file

@ -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"))
}),
)
})

View file

@ -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()
})
})