Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 56 additions & 39 deletions packages/core/src/catalog.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 = {
Expand All @@ -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
}
}
}

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

return Option.getOrUndefined(
pipe(
candidates,
Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
),
)
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)
}
}),
},
}
Expand All @@ -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 changes: 6 additions & 0 deletions packages/core/src/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
},
},
})
}),
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/plugin/provider/github-copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
72 changes: 49 additions & 23 deletions packages/core/src/session/runner/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}),
})
}),
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/session/title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -21,6 +22,7 @@ type Dependencies = {
}
readonly agents: AgentV2.Interface
readonly models: SessionRunnerModel.Interface
readonly catalog: Catalog.Interface
}

export interface Interface {
Expand All @@ -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[] = []
Expand Down Expand Up @@ -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),
})
Expand All @@ -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],
})
Loading
Loading