Skip to content
Merged
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
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
Expand Down
23 changes: 9 additions & 14 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export * as AgentV2 from "./agent"

import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
Expand Down Expand Up @@ -49,21 +48,19 @@ export interface Selection {
}

type Data = {
agents: Map<ID, Info>
agents: Map<ID, Types.DeepMutable<Info>>
default?: ID
}

export type Editor = {
export type Draft = {
list: () => readonly Info[]
get: (id: ID) => Info | undefined
default: (id: ID | undefined) => void
update: (id: ID, fn: (agent: Draft<Info>) => void) => void
update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void
remove: (id: ID) => void
}

export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly update: State.Interface<Data, Editor>["update"]
export interface Interface extends State.Transformable<Draft> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
Expand All @@ -73,21 +70,19 @@ export interface Interface {

export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}

enableMapSet()

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
initial: () => ({ agents: new Map() }),
editor: (draft) => ({
draft: (draft) => ({
list: () => Array.fromIterable(draft.agents.values()) as Info[],
get: (id) => draft.agents.get(id),
default: (id) => {
draft.default = id
},
update: (id, fn) => {
const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>)
if (!draft.agents.has(id)) draft.agents.set(id, current)
fn(current)
current.id = id
Expand All @@ -113,7 +108,7 @@ export const layer = Layer.effect(

return Service.of({
transform: state.transform,
update: state.update,
rebuild: state.rebuild,
get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id)
}),
Expand Down
144 changes: 51 additions & 93 deletions packages/core/src/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,21 @@
export * as Catalog from "./catalog"

import { Array, Context, Effect, Layer, Option, Order, pipe, Schema, Scope, Stream } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { ModelV2 } from "./model"
import { ModelRequest } from "./model-request"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Integration } from "./integration"

export type ProviderRecord = {
provider: ProviderV2.Info
models: Map<ModelV2.ID, ModelV2.Info>
provider: ProviderV2.MutableInfo
models: Map<ModelV2.ID, ModelV2.MutableInfo>
}

export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }

export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"CatalogV2.ProviderNotFound",
{
providerID: ProviderV2.ID,
},
) {}

export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
}) {}

export const PolicyActions = Schema.Literals(["provider.use"])

export const Event = {
Expand All @@ -42,16 +27,16 @@ type Data = {
defaultModel?: DefaultModel
}

export type Editor = {
export type Draft = {
provider: {
list: () => readonly ProviderRecord[]
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID) => void
}
model: {
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
default: {
get: () => DefaultModel | undefined
Expand All @@ -60,38 +45,29 @@ export type Editor = {
}
}

export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
export interface Interface extends State.Transformable<Draft> {
readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]>
}
readonly model: {
readonly get: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
}
}

export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}

enableMapSet()

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const integrations = yield* Integration.Service
const scope = yield* Scope.Scope

const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
if (provider.disabled) return false
Expand Down Expand Up @@ -120,32 +96,26 @@ export const layer = Layer.effect(
})
}

function* getRecord(providerID: ProviderV2.ID) {
const match = state.get().providers.get(providerID)
if (!match) return yield* new ProviderNotFoundError({ providerID })
return match
}

const normalizeApi = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
const normalizeApi = (item: ProviderV2.MutableInfo | ModelV2.MutableInfo) => {
if (typeof item.request.body.baseURL !== "string") return
item.api.url = item.request.body.baseURL
delete item.request.body.baseURL
}

const state = State.create<Data, Editor>({
const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }),
editor: (draft) => {
const result: Editor = {
draft: (draft) => {
const result: Draft = {
provider: {
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
get: (providerID) => draft.providers.get(providerID),
update: (providerID, fn) => {
let current = draft.providers.get(providerID)
if (!current) {
current = castDraft({
provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.Info>(),
})
current = {
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}
draft.providers.set(providerID, current)
}
fn(current.provider)
Expand All @@ -160,13 +130,14 @@ export const layer = Layer.effect(
update: (providerID, modelID, fn) => {
let record = draft.providers.get(providerID)
if (!record) {
record = castDraft({
provider: ProviderV2.Info.empty(providerID),
models: new Map<ModelV2.ID, ModelV2.Info>(),
})
record = {
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}
draft.providers.set(providerID, record)
}
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
const model =
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model)
model.id = modelID
Expand All @@ -186,8 +157,7 @@ export const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
Expand All @@ -198,25 +168,13 @@ export const layer = Layer.effect(
yield* events.publish(Event.Updated, {})
}),
})
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
Stream.filter(
(event) =>
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
),
Stream.runForEach((event) =>
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
),
Effect.forkIn(scope, { startImmediately: true }),
)

const result: Interface = {
transform: state.transform,
rebuild: state.rebuild,

provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID)
return record.provider
return state.get().providers.get(providerID)?.provider
}),

all: Effect.fn("CatalogV2.provider.all")(function* () {
Expand All @@ -238,10 +196,10 @@ export const layer = Layer.effect(

model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = yield* getRecord(providerID)
const record = state.get().providers.get(providerID)
if (!record) return
const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return projectModel(model, record.provider)
return model && projectModel(model, record.provider)
}),

all: Effect.fn("CatalogV2.model.all")(function* () {
Expand All @@ -250,7 +208,7 @@ export const layer = Layer.effect(
Array.flatMap((record) => {
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
)
}),

Expand All @@ -262,31 +220,30 @@ export const layer = Layer.effect(
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
if (
Option.isSome(provider) &&
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model
const provider = yield* result.provider.get(defaultModel.providerID)
if (provider && (yield* result.provider.available()).some((item) => item.id === provider.id)) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID)
if (model?.enabled) return model
}
}

return pipe(
yield* result.model.available(),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
Array.head,
return Option.getOrUndefined(
pipe(
yield* result.model.available(),
Array.sortWith((item) => item.time.released, Order.flip(Order.Number)),
Array.head,
),
)
}),

small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>()
if (!record) return
const provider = record.provider

if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
}

const candidates = pipe(
Expand All @@ -302,7 +259,7 @@ export const layer = Layer.effect(
Array.map((model) => ({
model,
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30),
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),
Expand All @@ -319,10 +276,12 @@ export const layer = Layer.effect(
)
}

return pipe(
candidates,
Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
return Option.getOrUndefined(
pipe(
candidates,
Array.filter((item) => item.small),
(items) => (items.length > 0 ? pick(items) : pick(candidates)),
),
)
}),
},
Expand All @@ -336,6 +295,5 @@ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/

export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer),
)
Loading
Loading