mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 19:56:14 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb0f763603 | ||
|
|
d3bdc98457 | ||
|
|
ba89671d90 | ||
|
|
c5dde0cabb | ||
|
|
83c6e0f8ea | ||
|
|
3c71e4b4c0 |
@@ -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] })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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"))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user