Compare commits

...
Author SHA1 Message Date
Aiden Cline fb0f763603 fix(core): resolve hidden title models internally 2026-07-13 13:44:03 -05:00
Aiden Cline d3bdc98457 refactor(core): let plugins pin small models via catalog draft
Move copilot utility-model preference out of core selection into the
github-copilot plugin by exposing catalog.model.small set/get/remove on
the transform draft.
2026-07-12 21:21:41 -05:00
Aiden Cline ba89671d90 fix(core): prefer copilot utility models for small selection
Match V1 copilot title behavior by preferring picker-disabled utility
models before family priority, and cover title/small fallback edges.
2026-07-12 20:37:48 -05:00
Aiden Cline c5dde0cabb 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.
2026-07-12 20:07:06 -05:00
Aiden Cline 83c6e0f8ea refactor(core): inline title model selection
Keep single-use model selection at the title generation call site.
2026-07-12 16:11:05 -05:00
Aiden Cline 3c71e4b4c0 fix(core): use catalog small model for session titles
Prefer Catalog.model.small over the session model when the title agent
has no explicit model, falling back to the session model if small
selection is unavailable.
2026-07-12 15:55:06 -05:00
11 changed files with 583 additions and 120 deletions
+56 -39
View File
@@ -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"
@@ -21,6 +21,7 @@ export const Event = Catalog.Event
type Data = {
providers: Map<ProviderV2.ID, ProviderRecord>
defaultModel?: DefaultModel
smallModels: Map<ProviderV2.ID, ModelV2.ID>
}
export type Draft = {
@@ -38,6 +39,11 @@ export type Draft = {
get: () => DefaultModel | undefined
set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
}
small: {
get: (providerID: ProviderV2.ID) => ModelV2.ID | undefined
set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
remove: (providerID: ProviderV2.ID) => void
}
}
}
@@ -83,7 +89,7 @@ const layer = Layer.effect(
const state = State.create<Data, Draft>({
name: "catalog",
initial: () => ({ providers: new Map() }),
initial: () => ({ providers: new Map(), smallModels: new Map() }),
draft: (draft) => {
const result: Draft = {
provider: {
@@ -131,6 +137,15 @@ const layer = Layer.effect(
draft.defaultModel = { providerID, modelID }
},
},
small: {
get: (providerID) => draft.smallModels.get(providerID),
set: (providerID, modelID) => {
draft.smallModels.set(providerID, modelID)
},
remove: (providerID) => {
draft.smallModels.delete(providerID)
},
},
},
}
return result
@@ -217,48 +232,50 @@ 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)
// Plugin transforms may pin a small model (for example picker-disabled utility models).
const configured = state.get().smallModels.get(providerID)
if (configured) {
const model = record.models.get(configured)
if (model?.status === "active") return projectModel(model, provider)
}
const candidates = pipe(
const priority = providerID.startsWith("opencode")
? ["gpt-nano"]
: providerID.startsWith("github-copilot")
? ["gpt-mini", ...SMALL_MODEL_FAMILY_PRIORITY]
: SMALL_MODEL_FAMILY_PRIORITY
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 +284,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] })
+6
View File
@@ -148,6 +148,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
set: (providerID, modelID) =>
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
},
small: {
get: (providerID) => draft.model.small.get(ProviderV2.ID.make(providerID)),
set: (providerID, modelID) =>
draft.model.small.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
remove: (providerID) => draft.model.small.remove(ProviderV2.ID.make(providerID)),
},
},
})
}),
@@ -119,6 +119,8 @@ function shouldUseResponses(modelID: string) {
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
}
const UTILITY_MODELS = ["gpt-5.4-nano", "gpt-4.1", "gpt-4o", "gpt-4o-mini"]
export const GithubCopilotPlugin = define({
id: "opencode.provider.github-copilot",
effect: Effect.fn(function* (ctx) {
@@ -192,6 +194,13 @@ export const GithubCopilotPlugin = define({
model.enabled = false
})
}
// GitHub exposes utility models for title generation without including them in the picker.
const utility = UTILITY_MODELS.map((id) => ModelV2.ID.make(id)).find((id) => {
const model = evt.model.get(item.provider.id, id)
return model?.status === "active"
})
if (utility) evt.model.small.set(item.provider.id, utility)
else evt.model.small.remove(item.provider.id)
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
+49 -23
View File
@@ -88,12 +88,23 @@ export interface Resolved {
export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
readonly resolveCatalogModel: (
session: SessionSchema.Info,
model: ModelV2.Info,
) => Effect.Effect<Resolved, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
/** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
export const layerWith = (
resolve: Interface["resolve"],
resolveCatalogModel: Interface["resolveCatalogModel"] = (session, model) =>
resolve({
...session,
model: ModelV2.Ref.make({ id: model.id, providerID: model.providerID }),
}),
) => Layer.succeed(Service, Service.of({ resolve, resolveCatalogModel }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
@@ -307,7 +318,43 @@ const layer = Layer.effect(
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const resolveCatalogModel = Effect.fn("SessionRunnerModel.resolveCatalogModel")(function* (
session: SessionSchema.Info,
selected: ModelV2.Info,
variant?: ModelV2.VariantID,
) {
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const model = yield* resolve(
{
...session,
model: ModelV2.Ref.make({
id: selected.id,
providerID: selected.providerID,
...(variant === undefined ? {} : { variant }),
}),
},
selected,
connection ? yield* integrations.connection.resolve(connection) : undefined,
{
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
},
)
return {
model,
ref: ModelV2.Ref.make({
id: selected.id,
providerID: selected.providerID,
...(variant === undefined ? {} : { variant }),
}),
cost: selected.cost,
}
})
return Service.of({
resolveCatalogModel,
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
const defaultModel = session.model ? undefined : yield* catalog.model.default()
@@ -324,28 +371,7 @@ const layer = Layer.effect(
modelID: session.model.id,
})
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const model = yield* resolve(
session,
selected,
connection ? yield* integrations.connection.resolve(connection) : undefined,
{
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
},
)
return {
model,
ref: ModelV2.Ref.make({
id: selected.id,
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
cost: selected.cost,
}
return yield* resolveCatalogModel(session, selected, session.model?.variant)
}),
})
}),
+13 -3
View File
@@ -3,6 +3,7 @@ export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { makeLocationNode } from "../effect/app-node"
@@ -21,6 +22,7 @@ type Dependencies = {
}
readonly agents: AgentV2.Interface
readonly models: SessionRunnerModel.Interface
readonly catalog: Catalog.Interface
}
export interface Interface {
@@ -45,7 +47,14 @@ const make = (dependencies: Dependencies) => {
const resolved = yield* (
agent.model
? dependencies.models.resolve({ ...session, model: agent.model })
: dependencies.models.resolve(session)
: Effect.gen(function* () {
const providerID = session.model?.providerID ?? (yield* dependencies.catalog.model.default())?.providerID
const small = providerID ? yield* dependencies.catalog.model.small(providerID) : undefined
if (!small) return yield* dependencies.models.resolve(session)
return yield* dependencies.models
.resolveCatalogModel(session, small)
.pipe(Effect.catch(() => dependencies.models.resolve(session)))
})
).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!resolved) return
const chunks: string[] = []
@@ -90,8 +99,9 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service
const models = yield* SessionRunnerModel.Service
const catalog = yield* Catalog.Service
const database = yield* Database.Service
const title = make({ events, llm, agents, models })
const title = make({ events, llm, agents, models, catalog })
return Service.of({
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
})
@@ -101,5 +111,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node],
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Catalog.node, Database.node],
})
+199 -28
View File
@@ -290,45 +290,216 @@ 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 a plugin-configured small model override", () =>
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("mini"), (model) => {
model.family = ModelV2.Family.make("gpt-mini")
model.time.released = Date.now()
})
catalog.model.update(providerID, ModelV2.ID.make("gpt-5.4-nano"), (model) => {
model.enabled = false
model.time.released = Date.now() - 2000
})
catalog.model.small.set(providerID, ModelV2.ID.make("gpt-5.4-nano"))
})
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("gpt-5.4-nano"))
expect((yield* catalog.model.available()).some((model) => model.id === ModelV2.ID.make("gpt-5.4-nano"))).toBe(
false,
)
}),
)
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 prefers regional bedrock deployments when global is missing", () =>
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("claude-haiku"), (model) => {
model.family = ModelV2.Family.make("claude-haiku")
model.time.released = Date.now()
})
catalog.model.update(providerID, ModelV2.ID.make("us.claude-haiku"), (model) => {
model.family = ModelV2.Family.make("claude-haiku")
model.time.released = Date.now() - 1000
})
catalog.model.update(providerID, ModelV2.ID.make("eu.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("us.claude-haiku"))
}),
)
it.effect("small model prefers unprefixed bedrock deployments when region prefixes are missing", () =>
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: "ap-northeast-1" }
})
catalog.model.update(providerID, ModelV2.ID.make("eu.claude-haiku"), (model) => {
model.family = ModelV2.Family.make("claude-haiku")
model.time.released = Date.now()
})
catalog.model.update(providerID, ModelV2.ID.make("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("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()
}
}),
)
})
+6
View File
@@ -178,6 +178,12 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
set: (providerID, modelID) =>
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
},
small: {
get: (providerID) => draft.model.small.get(ProviderV2.ID.make(providerID)),
set: (providerID, modelID) =>
draft.model.small.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
remove: (providerID) => draft.model.small.remove(ProviderV2.ID.make(providerID)),
},
},
}),
),
@@ -289,6 +289,28 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("pins picker-hidden utility models for internal small-model work", () =>
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("gpt-4.1"), (model) => {
model.enabled = false
})
catalog.model.update(providerID, ModelV2.ID.make("gpt-5.4-nano"), (model) => {
model.enabled = false
})
})
yield* addPlugin()
expect((yield* catalog.model.small(providerID))?.id).toBe(ModelV2.ID.make("gpt-5.4-nano"))
expect((yield* catalog.model.available()).some((model) => model.id === ModelV2.ID.make("gpt-5.4-nano"))).toBe(
false,
)
}),
)
it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -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"))
}),
)
})
+204 -21
View File
@@ -2,11 +2,14 @@ import { expect } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm"
import { OpenAIChat } from "@opencode-ai/llm/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -18,10 +21,12 @@ 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 { DateTime, Effect, Layer, Stream } from "effect"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
let resolvedModels: Array<{ id: string; provider: string } | undefined> = []
let failSmallResolve = false
const model = Model.make({
id: "title-model",
provider: "test",
@@ -36,7 +41,35 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"),
})
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
resolve: (session) => {
resolvedModels.push(session.model ? { id: session.model.id, provider: session.model.providerID } : undefined)
if (failSmallResolve && session.model?.id === "mini") {
return Effect.fail(new Error("small unavailable") as never)
}
const selected = session.model
? Model.make({
id: session.model.id,
provider: session.model.providerID,
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
: model
return Effect.succeed(SessionRunnerModel.resolved(selected))
},
resolveCatalogModel: (session, selected) => {
resolvedModels.push({ id: selected.id, provider: selected.providerID })
if (failSmallResolve && selected.id === ModelV2.ID.make("mini")) {
return Effect.fail(new Error("small unavailable") as never)
}
return Effect.succeed(
SessionRunnerModel.resolved(
Model.make({
id: selected.id,
provider: selected.providerID,
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
}),
),
)
},
})
const it = testEffect(
AppNodeBuilder.build(
@@ -46,6 +79,7 @@ const it = testEffect(
SessionProjector.node,
SessionStore.node,
AgentV2.node,
Catalog.node,
SessionTitle.node,
]),
[
@@ -55,7 +89,10 @@ const it = testEffect(
),
)
const insertSession = (id: SessionV2.ID) =>
const insertSession = (
id: SessionV2.ID,
model?: { id: string; providerID: string },
) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
@@ -73,6 +110,14 @@ const insertSession = (id: SessionV2.ID) =>
directory: "/project",
title: "New session - fake",
version: "test",
...(model
? {
model: {
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.providerID),
},
}
: {}),
})
.onConflictDoNothing()
.run()
@@ -94,17 +139,47 @@ const prompt = (sessionID: SessionV2.ID, text: string) =>
})
})
it.effect("generates a title from the sole user message and renames the session", () =>
const enableTitleAgent = (model?: { id: string; providerID: string }) =>
Effect.gen(function* () {
requests = []
const agentService = yield* AgentV2.Service
yield* agentService.transform((editor) => {
editor.update(AgentV2.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
if (model) {
agent.model = {
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.providerID),
}
}
})
})
})
const seedSmallModel = () =>
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("main"), (model) => {
model.time.released = Date.now()
})
catalog.model.update(providerID, ModelV2.ID.make("mini"), (model) => {
model.enabled = false
model.family = ModelV2.Family.make("gpt-nano")
model.time.released = Date.now()
})
catalog.model.small.set(providerID, ModelV2.ID.make("mini"))
})
})
it.effect("generates a title from the sole user message and renames the session", () =>
Effect.gen(function* () {
requests = []
resolvedModels = []
yield* enableTitleAgent()
const sessionID = SessionV2.ID.make("ses_title_generate")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
@@ -123,17 +198,130 @@ it.effect("generates a title from the sole user message and renames the session"
}),
)
it.effect("prefers the catalog small model over the session model", () =>
Effect.gen(function* () {
requests = []
resolvedModels = []
yield* enableTitleAgent()
yield* seedSmallModel()
const sessionID = SessionV2.ID.make("ses_title_small_model")
yield* insertSession(sessionID, { id: "main", providerID: "test" })
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
expect(resolvedModels).toEqual([{ id: "mini", provider: "test" }])
expect(String(requests[0]?.model.id)).toBe("mini")
}),
)
it.effect("prefers the title agent model over the catalog small model", () =>
Effect.gen(function* () {
requests = []
resolvedModels = []
yield* enableTitleAgent({ id: "agent-title", providerID: "test" })
yield* seedSmallModel()
const sessionID = SessionV2.ID.make("ses_title_agent_model")
yield* insertSession(sessionID, { id: "main", providerID: "test" })
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
expect(resolvedModels).toEqual([{ id: "agent-title", provider: "test" }])
expect(String(requests[0]?.model.id)).toBe("agent-title")
}),
)
it.effect("falls back to the session model when no small model exists", () =>
Effect.gen(function* () {
requests = []
resolvedModels = []
failSmallResolve = false
yield* enableTitleAgent()
const sessionID = SessionV2.ID.make("ses_title_session_fallback")
yield* insertSession(sessionID, { id: "main", providerID: "test" })
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
expect(resolvedModels).toEqual([{ id: "main", provider: "test" }])
expect(String(requests[0]?.model.id)).toBe("main")
}),
)
it.effect("falls back to the session model when small model resolution fails", () =>
Effect.gen(function* () {
requests = []
resolvedModels = []
failSmallResolve = true
yield* enableTitleAgent()
yield* seedSmallModel()
const sessionID = SessionV2.ID.make("ses_title_small_resolve_fail")
yield* insertSession(sessionID, { id: "main", providerID: "test" })
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
expect(resolvedModels).toEqual([
{ id: "mini", provider: "test" },
{ id: "main", provider: "test" },
])
expect(String(requests[0]?.model.id)).toBe("main")
}),
)
it.effect("uses the catalog default provider when the session has no model", () =>
Effect.gen(function* () {
requests = []
resolvedModels = []
failSmallResolve = false
yield* enableTitleAgent()
yield* seedSmallModel()
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.model.default.set(ProviderV2.ID.make("test"), ModelV2.ID.make("main"))
})
const sessionID = SessionV2.ID.make("ses_title_default_provider")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
const store = yield* SessionStore.Service
const session = yield* store
.get(sessionID)
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
const title = yield* SessionTitle.Service
yield* title.generateForFirstPrompt(session)
expect(resolvedModels).toEqual([{ id: "mini", provider: "test" }])
expect(String(requests[0]?.model.id)).toBe("mini")
}),
)
it.effect("does not generate once a second user message exists", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* AgentV2.Service
yield* agentService.transform((editor) => {
editor.update(AgentV2.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
resolvedModels = []
yield* enableTitleAgent()
const sessionID = SessionV2.ID.make("ses_title_second_message")
yield* insertSession(sessionID)
yield* prompt(sessionID, "First message")
@@ -155,14 +343,8 @@ it.effect("does not generate once a second user message exists", () =>
it.effect("does not generate for a child session", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* AgentV2.Service
yield* agentService.transform((editor) => {
editor.update(AgentV2.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
resolvedModels = []
yield* enableTitleAgent()
const sessionID = SessionV2.ID.make("ses_title_child")
const { db } = yield* Database.Service
yield* db
@@ -201,6 +383,7 @@ it.effect("does not generate for a child session", () =>
it.effect("does not generate when the title agent is removed", () =>
Effect.gen(function* () {
requests = []
resolvedModels = []
const sessionID = SessionV2.ID.make("ses_title_no_agent")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
+5
View File
@@ -23,6 +23,11 @@ export interface CatalogDraft {
get(): { providerID: string; modelID: string } | undefined
set(providerID: string, modelID: string): void
}
readonly small: {
get(providerID: string): string | undefined
set(providerID: string, modelID: string): void
remove(providerID: string): void
}
}
}