feat(core): add location-scoped config loading (#29625)
This commit is contained in:
parent
5fb85a6aa3
commit
9583e08be4
89 changed files with 3509 additions and 527 deletions
|
|
@ -63,6 +63,7 @@
|
|||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
|
|
|
|||
|
|
@ -1,147 +1,104 @@
|
|||
export * as AgentV2 from "./agent"
|
||||
|
||||
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import { Array, Context, Effect, Layer, Schema } from "effect"
|
||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { PositiveInt } from "./schema"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" })
|
||||
export type Mode = typeof Mode.Type
|
||||
export const Color = Schema.Union([
|
||||
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
||||
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
||||
])
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: ID,
|
||||
description: Schema.optional(Schema.String),
|
||||
mode: Mode,
|
||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||
color: Schema.String.pipe(Schema.optional),
|
||||
permission: PermissionV2.Ruleset,
|
||||
export class Info extends Schema.Class<Info>("AgentV2.Info")({
|
||||
id: ID,
|
||||
model: ModelV2.Ref.pipe(Schema.optional),
|
||||
options: ProviderV2.Options,
|
||||
system: Schema.String.pipe(Schema.optional),
|
||||
options: ProviderV2.Options.pipe(Schema.optional),
|
||||
steps: Schema.Int.pipe(Schema.optional),
|
||||
}).annotate({ identifier: "AgentV2.Info" })
|
||||
export type Info = typeof Info.Type
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]),
|
||||
hidden: Schema.Boolean,
|
||||
color: Color.pipe(Schema.optional),
|
||||
steps: PositiveInt.pipe(Schema.optional),
|
||||
permissions: PermissionV2.Ruleset,
|
||||
}) {
|
||||
static empty(id: ID) {
|
||||
return new Info({
|
||||
id,
|
||||
options: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
mode: "all",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("AgentV2.NotFound", {
|
||||
agent: ID,
|
||||
}) {}
|
||||
type Data = {
|
||||
agents: Map<ID, Info>
|
||||
}
|
||||
|
||||
export class InvalidDefaultError extends Schema.TaggedErrorClass<InvalidDefaultError>()("AgentV2.InvalidDefault", {
|
||||
agent: ID,
|
||||
reason: Schema.Literals(["missing", "subagent", "hidden"]),
|
||||
}) {}
|
||||
|
||||
export class NoDefaultError extends Schema.TaggedErrorClass<NoDefaultError>()("AgentV2.NoDefault", {}) {}
|
||||
export type Editor = {
|
||||
list: () => readonly Info[]
|
||||
get: (id: ID) => Info | undefined
|
||||
update: (id: ID, fn: (agent: Draft<Info>) => void) => void
|
||||
remove: (id: ID) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (agent: ID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly update: (agent: ID, fn: (agent: Draft<Info>) => void) => Effect.Effect<void>
|
||||
readonly remove: (agent: ID) => Effect.Effect<void>
|
||||
readonly defaultInfo: () => Effect.Effect<Info, InvalidDefaultError | NoDefaultError>
|
||||
readonly defaultAgent: () => Effect.Effect<ID, InvalidDefaultError | NoDefaultError>
|
||||
readonly setDefault: (agent: ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
let agents = HashMap.empty<ID, Info>()
|
||||
let defaultAgent: ID | undefined
|
||||
|
||||
const result: Interface = {
|
||||
get: Effect.fn("AgentV2.get")(function* (agent) {
|
||||
const match = HashMap.get(agents, agent)
|
||||
if (!match.valueOrUndefined) return yield* new NotFoundError({ agent })
|
||||
return match.value
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ agents: new Map() }),
|
||||
editor: (draft) => ({
|
||||
list: () => Array.fromIterable(draft.agents.values()) as Info[],
|
||||
get: (id) => draft.agents.get(id),
|
||||
update: (id, fn) => {
|
||||
const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
|
||||
if (!draft.agents.has(id)) draft.agents.set(id, current)
|
||||
fn(current)
|
||||
current.id = id
|
||||
},
|
||||
remove: (id) => {
|
||||
draft.agents.delete(id)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
list: Effect.fn("AgentV2.list")(function* () {
|
||||
return pipe(
|
||||
HashMap.toValues(agents),
|
||||
Array.sortWith((agent) => agent.name, Order.String),
|
||||
)
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
update: state.update,
|
||||
get: Effect.fn("AgentV2.get")(function* (id) {
|
||||
return state.get().agents.get(id)
|
||||
}),
|
||||
|
||||
update: Effect.fnUntraced(function* (agent, fn) {
|
||||
const next = produce(
|
||||
HashMap.get(agents, agent).pipe(
|
||||
Option.getOrElse(
|
||||
() =>
|
||||
({
|
||||
name: agent,
|
||||
mode: "all",
|
||||
permission: [],
|
||||
options: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
}) satisfies Info,
|
||||
),
|
||||
),
|
||||
fn,
|
||||
)
|
||||
const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false })
|
||||
if (updated.cancel) return
|
||||
agents = HashMap.set(agents, agent, { ...updated.agent, name: agent })
|
||||
all: Effect.fn("AgentV2.all")(function* () {
|
||||
return Array.fromIterable(state.get().agents.values())
|
||||
}),
|
||||
|
||||
remove: Effect.fn("AgentV2.remove")(function* (agent) {
|
||||
const existing = Option.getOrUndefined(HashMap.get(agents, agent))
|
||||
if (!existing) return
|
||||
if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return
|
||||
agents = HashMap.remove(agents, agent)
|
||||
if (defaultAgent === agent) defaultAgent = undefined
|
||||
}),
|
||||
|
||||
defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () {
|
||||
const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent })
|
||||
const selected = updated.agent
|
||||
if (selected) {
|
||||
const agent = yield* result
|
||||
.get(selected)
|
||||
.pipe(
|
||||
Effect.catchTag("AgentV2.NotFound", () =>
|
||||
Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })),
|
||||
),
|
||||
)
|
||||
if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" })
|
||||
if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" })
|
||||
return agent
|
||||
}
|
||||
|
||||
const visible = pipe(
|
||||
yield* result.list(),
|
||||
Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true),
|
||||
)
|
||||
if (Option.isSome(visible)) return visible.value
|
||||
return yield* new NoDefaultError()
|
||||
}),
|
||||
|
||||
defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () {
|
||||
return (yield* result.defaultInfo()).name
|
||||
}),
|
||||
|
||||
setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) {
|
||||
yield* result.get(agent)
|
||||
defaultAgent = agent
|
||||
}),
|
||||
}
|
||||
|
||||
return Service.of(result)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
|
||||
export const defaultLayer = layer
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
export * as Catalog from "./catalog"
|
||||
|
||||
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import { Context, Effect, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
|
||||
import { castDraft, enableMapSet, type Draft } from "immer"
|
||||
import { ModelV2 } from "./model"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { Location } from "./location"
|
||||
import { EventV2 } from "./event"
|
||||
import { Policy } from "./policy"
|
||||
import { State } from "./state"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
models: Map<ModelV2.ID, ModelV2.Info>
|
||||
}
|
||||
|
||||
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
|
||||
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
|
||||
"CatalogV2.ProviderNotFound",
|
||||
{
|
||||
|
|
@ -25,6 +29,8 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
|
|||
modelID: ModelV2.ID,
|
||||
}) {}
|
||||
|
||||
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||
|
||||
export const Event = {
|
||||
ModelUpdated: EventV2.define({
|
||||
type: "catalog.model.updated",
|
||||
|
|
@ -34,24 +40,31 @@ export const Event = {
|
|||
}),
|
||||
}
|
||||
|
||||
export type Context = {
|
||||
data: readonly ProviderRecord[]
|
||||
updateProvider: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
|
||||
updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
|
||||
type Data = {
|
||||
providers: Map<ProviderV2.ID, ProviderRecord>
|
||||
defaultModel?: DefaultModel
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
provider: {
|
||||
list: () => readonly ProviderRecord[]
|
||||
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
|
||||
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => 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
|
||||
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
||||
default: {
|
||||
get: () => DefaultModel | undefined
|
||||
set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Loader = (update: (ctx: Context) => void) => Effect.Effect<void>
|
||||
|
||||
export interface Interface {
|
||||
readonly loader: () => Effect.Effect<Loader, never, Scope.Scope>
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly provider: {
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
|
||||
readonly all: () => Effect.Effect<ProviderV2.Info[]>
|
||||
|
|
@ -65,29 +78,25 @@ export interface Interface {
|
|||
readonly all: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
|
||||
readonly setDefault: (
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
) => Effect.Effect<void, ProviderNotFoundError | ModelNotFoundError>
|
||||
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
|
||||
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
yield* Location.Service
|
||||
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
|
||||
let loaders: { update: (ctx: Context) => void }[] = []
|
||||
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
|
||||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const policy = yield* Policy.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const resolve = (model: ModelV2.Info) => {
|
||||
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
|
||||
const provider = state.get().providers.get(model.providerID)!.provider
|
||||
const endpoint =
|
||||
model.endpoint.type === "unknown"
|
||||
? provider.endpoint
|
||||
|
|
@ -120,9 +129,9 @@ export const layer = Layer.effect(
|
|||
}
|
||||
|
||||
function* getRecord(providerID: ProviderV2.ID) {
|
||||
const match = HashMap.get(records, providerID)
|
||||
if (!match.valueOrUndefined) return yield* new ProviderNotFoundError({ providerID })
|
||||
return match.value
|
||||
const match = state.get().providers.get(providerID)
|
||||
if (!match) return yield* new ProviderNotFoundError({ providerID })
|
||||
return match
|
||||
}
|
||||
|
||||
const normalizeEndpoint = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
|
||||
|
|
@ -131,114 +140,73 @@ export const layer = Layer.effect(
|
|||
delete item.options.aisdk.provider.baseURL
|
||||
}
|
||||
|
||||
const clone = (input: HashMap.HashMap<ProviderV2.ID, ProviderRecord>) =>
|
||||
HashMap.fromIterable(
|
||||
HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const),
|
||||
)
|
||||
|
||||
const context = (draft: {
|
||||
records: HashMap.HashMap<ProviderV2.ID, ProviderRecord>
|
||||
data: ProviderRecord[]
|
||||
}): Context => {
|
||||
const result: Context = {
|
||||
data: draft.data,
|
||||
updateProvider: (providerID, fn) => result.provider.update(providerID, fn),
|
||||
updateModel: (providerID, modelID, fn) => result.model.update(providerID, modelID, fn),
|
||||
provider: {
|
||||
update: (providerID, fn) => {
|
||||
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
|
||||
const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
|
||||
fn(draft)
|
||||
normalizeEndpoint(draft)
|
||||
})
|
||||
const next = {
|
||||
provider,
|
||||
models: current?.models ?? new Map<ModelV2.ID, ModelV2.Info>(),
|
||||
}
|
||||
draft.records = HashMap.set(draft.records, providerID, next)
|
||||
const index = draft.data.findIndex((item) => item.provider.id === providerID)
|
||||
if (index === -1) draft.data.push(next)
|
||||
else draft.data[index] = next
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
editor: (draft) => {
|
||||
const result: Editor = {
|
||||
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>(),
|
||||
})
|
||||
draft.providers.set(providerID, current)
|
||||
}
|
||||
fn(current.provider)
|
||||
normalizeEndpoint(current.provider)
|
||||
},
|
||||
remove: (providerID) => {
|
||||
draft.providers.delete(providerID)
|
||||
},
|
||||
},
|
||||
remove: (providerID) => {
|
||||
draft.records = HashMap.remove(draft.records, providerID)
|
||||
const index = draft.data.findIndex((item) => item.provider.id === providerID)
|
||||
if (index !== -1) draft.data.splice(index, 1)
|
||||
model: {
|
||||
get: (providerID, modelID) => draft.providers.get(providerID)?.models.get(modelID),
|
||||
update: (providerID, modelID, fn) => {
|
||||
result.provider.update(providerID, () => {})
|
||||
const record = draft.providers.get(providerID)!
|
||||
const model = record.models.get(modelID) ?? castDraft(ModelV2.Info.empty(providerID, modelID))
|
||||
if (!record.models.has(modelID)) record.models.set(modelID, model)
|
||||
fn(model)
|
||||
model.id = modelID
|
||||
model.providerID = providerID
|
||||
normalizeEndpoint(model)
|
||||
},
|
||||
remove: (providerID, modelID) => {
|
||||
draft.providers.get(providerID)?.models.delete(modelID)
|
||||
},
|
||||
default: {
|
||||
get: () => draft.defaultModel,
|
||||
set: (providerID, modelID) => {
|
||||
draft.defaultModel = { providerID, modelID }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
update: (providerID, modelID, fn) => {
|
||||
const current = Option.getOrThrow(HashMap.get(draft.records, providerID))
|
||||
const model = produce(current.models.get(modelID) ?? ModelV2.Info.empty(providerID, modelID), (draft) => {
|
||||
fn(draft)
|
||||
normalizeEndpoint(draft)
|
||||
})
|
||||
const next = {
|
||||
provider: current.provider,
|
||||
models: new Map(current.models).set(modelID, new ModelV2.Info({ ...model, id: modelID, providerID })),
|
||||
}
|
||||
draft.records = HashMap.set(draft.records, providerID, next)
|
||||
const index = draft.data.findIndex((item) => item.provider.id === providerID)
|
||||
if (index === -1) draft.data.push(next)
|
||||
else draft.data[index] = next
|
||||
},
|
||||
remove: (providerID, modelID) => {
|
||||
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
|
||||
if (!current) return
|
||||
const next = {
|
||||
provider: current.provider,
|
||||
models: new Map(current.models),
|
||||
}
|
||||
next.models.delete(modelID)
|
||||
draft.records = HashMap.set(draft.records, providerID, next)
|
||||
const index = draft.data.findIndex((item) => item.provider.id === providerID)
|
||||
if (index !== -1) draft.data[index] = next
|
||||
},
|
||||
},
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const transform = Effect.fn("CatalogV2.transform")(function* () {
|
||||
const draft = { records: clone(records), data: HashMap.toValues(records) }
|
||||
yield* plugin.trigger("catalog.transform", context(draft), {})
|
||||
records = draft.records
|
||||
}
|
||||
return result
|
||||
},
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
|
||||
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
catalog.provider.remove(record.provider.id)
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const rebuild = Effect.fn("CatalogV2.rebuild")(function* () {
|
||||
const draft = { records: HashMap.empty<ProviderV2.ID, ProviderRecord>(), data: [] as ProviderRecord[] }
|
||||
for (const loader of loaders) loader.update(context(draft))
|
||||
yield* plugin.trigger("catalog.transform", context(draft), {})
|
||||
records = draft.records
|
||||
})
|
||||
|
||||
yield* plugin.added().pipe(
|
||||
Stream.runForEach((id) =>
|
||||
Effect.gen(function* () {
|
||||
const draft = { records: clone(records), data: HashMap.toValues(records) }
|
||||
yield* plugin.triggerFor(id, "catalog.transform", context(draft), {})
|
||||
records = draft.records
|
||||
}),
|
||||
yield* events.subscribe(PluginV2.Event.Added).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
const result: Interface = {
|
||||
loader: Effect.fn("CatalogV2.loader")(function* () {
|
||||
const loader = { update: (_ctx: Context) => {} }
|
||||
loaders = [...loaders, loader]
|
||||
const scope = yield* Scope.Scope
|
||||
yield* Scope.addFinalizer(
|
||||
scope,
|
||||
Effect.sync(() => {
|
||||
loaders = loaders.filter((item) => item !== loader)
|
||||
}).pipe(Effect.andThen(rebuild())),
|
||||
)
|
||||
return Effect.fnUntraced(function* (update) {
|
||||
loader.update = update
|
||||
yield* rebuild()
|
||||
})
|
||||
}),
|
||||
transform: state.transform,
|
||||
|
||||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
|
|
@ -247,11 +215,11 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||
return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider)
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
return globalThis.Array.from(HashMap.values(records))
|
||||
return Array.fromIterable(state.get().providers.values())
|
||||
.map((record) => record.provider)
|
||||
.filter((provider) => provider.enabled)
|
||||
}),
|
||||
|
|
@ -267,9 +235,8 @@ export const layer = Layer.effect(
|
|||
|
||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||
return pipe(
|
||||
records,
|
||||
HashMap.toValues,
|
||||
Array.flatMap((record) => globalThis.Array.from(record.models.values())),
|
||||
Array.fromIterable(state.get().providers.values()),
|
||||
Array.flatMap((record) => Array.fromIterable(record.models.values())),
|
||||
Array.map(resolve),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
)
|
||||
|
|
@ -277,12 +244,13 @@ export const layer = Layer.effect(
|
|||
|
||||
available: Effect.fn("CatalogV2.model.available")(function* () {
|
||||
return (yield* result.model.all()).filter((model) => {
|
||||
const record = Option.getOrUndefined(HashMap.get(records, model.providerID))
|
||||
const record = state.get().providers.get(model.providerID)
|
||||
return record?.provider.enabled !== false && model.enabled
|
||||
})
|
||||
}),
|
||||
|
||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||
const defaultModel = state.get().defaultModel
|
||||
if (defaultModel) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
|
||||
if (Option.isSome(model) && model.value.enabled) return model
|
||||
|
|
@ -295,13 +263,8 @@ export const layer = Layer.effect(
|
|||
)
|
||||
}),
|
||||
|
||||
setDefault: Effect.fn("CatalogV2.model.setDefault")(function* (providerID, modelID) {
|
||||
yield* result.model.get(providerID, modelID)
|
||||
defaultModel = { providerID, modelID }
|
||||
}),
|
||||
|
||||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||
const record = Option.getOrUndefined(HashMap.get(records, providerID))
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return Option.none<ModelV2.Info>()
|
||||
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
|
|
@ -310,7 +273,7 @@ export const layer = Layer.effect(
|
|||
}
|
||||
|
||||
const candidates = pipe(
|
||||
globalThis.Array.from(record.models.values()),
|
||||
Array.fromIterable(record.models.values()),
|
||||
Array.filter(
|
||||
(model) =>
|
||||
model.providerID === providerID &&
|
||||
|
|
|
|||
202
packages/core/src/config.ts
Normal file
202
packages/core/src/config.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
export * as Config from "./config"
|
||||
|
||||
import path from "path"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { Policy } from "./policy"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
import { ConfigAttachments } from "./config/attachments"
|
||||
import { ConfigCompaction } from "./config/compaction"
|
||||
import { ConfigExperimental } from "./config/experimental"
|
||||
import { ConfigFormatter } from "./config/formatter"
|
||||
import { ConfigLSP } from "./config/lsp"
|
||||
import { ConfigMCP } from "./config/mcp"
|
||||
import { ConfigPlugin } from "./config/plugin"
|
||||
import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
description: "JSON schema reference for configuration validation",
|
||||
}),
|
||||
shell: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default shell to use for terminal and shell tool execution",
|
||||
}),
|
||||
model: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default model to use when no session or agent model is selected",
|
||||
}),
|
||||
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]).pipe(Schema.optional).annotate({
|
||||
description: "Automatically update or notify when a new version is available",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
}),
|
||||
enterprise: Schema.Struct({
|
||||
url: Schema.String.pipe(Schema.optional),
|
||||
}).pipe(Schema.optional).annotate({
|
||||
description: "Enterprise sharing service configuration",
|
||||
}),
|
||||
username: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
permissions: PermissionV2.Ruleset.pipe(Schema.optional).annotate({
|
||||
description: "Ordered tool permission rules applied to agent tool use",
|
||||
}),
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||
description: "Named built-in agent overrides and custom agent definitions",
|
||||
}),
|
||||
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Enable snapshots used for undo and revert behavior",
|
||||
}),
|
||||
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
|
||||
description: "Filesystem watcher configuration",
|
||||
}),
|
||||
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in formatters or configure formatter overrides",
|
||||
}),
|
||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in language servers or configure server overrides",
|
||||
}),
|
||||
attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({
|
||||
description: "Attachment processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
}),
|
||||
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
|
||||
description: "MCP server configuration",
|
||||
}),
|
||||
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
|
||||
description: "Conversation compaction behavior",
|
||||
}),
|
||||
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs to discover skills from",
|
||||
}),
|
||||
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs supplying ambient instructions",
|
||||
}),
|
||||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered external plugin packages to load",
|
||||
}),
|
||||
experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const FileSource = Schema.Struct({
|
||||
type: Schema.Literal("file"),
|
||||
path: Schema.String,
|
||||
}).annotate({ identifier: "Config.FileSource" })
|
||||
export type FileSource = typeof FileSource.Type
|
||||
|
||||
export const MemorySource = Schema.Struct({
|
||||
type: Schema.Literal("memory"),
|
||||
}).annotate({ identifier: "Config.MemorySource" })
|
||||
export type MemorySource = typeof MemorySource.Type
|
||||
|
||||
export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
export class Loaded extends Schema.Class<Loaded>("Config.Loaded")({
|
||||
source: Source,
|
||||
info: Info,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
/** Returns supplemental config directories from lowest to highest priority. */
|
||||
readonly directories: () => Effect.Effect<AbsolutePath[]>
|
||||
/** Loads location config files from lowest to highest priority. */
|
||||
readonly get: () => Effect.Effect<Loaded[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const policy = yield* Policy.Service
|
||||
const names = ["config.json", "opencode.json", "opencode.jsonc"]
|
||||
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
const text = yield* fs.readFileStringSafe(filepath)
|
||||
if (!text) return
|
||||
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return
|
||||
|
||||
// Accept legacy fields while v2 is migrated incrementally; recognized
|
||||
// fields still have to satisfy the v2 schema.
|
||||
const info = Option.getOrUndefined(
|
||||
Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }),
|
||||
)
|
||||
if (!info) return
|
||||
return new Loaded({ source: { type: "file", path: filepath }, info })
|
||||
})
|
||||
|
||||
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
|
||||
return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
|
||||
Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
|
||||
)
|
||||
})
|
||||
|
||||
const globalDirectory = AbsolutePath.make(global.config)
|
||||
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
|
||||
// Read configuration once when this location opens. Later calls reuse these
|
||||
// values until the location is reopened.
|
||||
const directories = locationIsGlobal
|
||||
? [globalDirectory]
|
||||
: [
|
||||
globalDirectory,
|
||||
...(yield* fs
|
||||
.up({ targets: [".opencode"], start: location.directory, stop: location.project.directory })
|
||||
.pipe(Effect.orDie))
|
||||
.toReversed()
|
||||
.map((directory) => AbsolutePath.make(directory)),
|
||||
]
|
||||
// A config closer to the opened directory should win over one higher up.
|
||||
// Search starts nearby, so reverse the results before applying them.
|
||||
const directPaths = locationIsGlobal
|
||||
? []
|
||||
: (yield* fs
|
||||
.up({ targets: names.toReversed(), start: location.directory, stop: location.project.directory })
|
||||
.pipe(Effect.orDie)).toReversed()
|
||||
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
|
||||
)
|
||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||
// Apply general settings first and more specific settings last:
|
||||
// global config, project files, then `.opencode` files.
|
||||
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
|
||||
// Rules use the opposite order so a user-global rule can override a
|
||||
// repository rule. Statement order inside each file stays unchanged.
|
||||
yield* policy.load(configs.toReversed().flatMap((config) => config.info.experimental?.policies ?? []))
|
||||
|
||||
return Service.of({
|
||||
directories: Effect.fn("Config.directories")(function* () {
|
||||
return directories
|
||||
}),
|
||||
get: Effect.fn("Config.get")(function* () {
|
||||
return configs
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Global.defaultLayer),
|
||||
)
|
||||
25
packages/core/src/config/agent.ts
Normal file
25
packages/core/src/config/agent.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export * as ConfigAgent from "./agent"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ConfigProvider } from "./provider"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export const Color = Schema.Union([
|
||||
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
||||
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
||||
])
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
|
||||
model: Schema.String.pipe(Schema.optional),
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
options: ConfigProvider.Options.pipe(Schema.optional),
|
||||
system: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
|
||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||
color: Color.pipe(Schema.optional),
|
||||
steps: PositiveInt.pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
permissions: PermissionV2.Ruleset.pipe(Schema.optional),
|
||||
}) {}
|
||||
15
packages/core/src/config/attachments.ts
Normal file
15
packages/core/src/config/attachments.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export * as ConfigAttachments from "./attachments"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Image extends Schema.Class<Image>("ConfigV2.Attachments.Image")({
|
||||
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
||||
max_width: PositiveInt.pipe(Schema.optional),
|
||||
max_height: PositiveInt.pipe(Schema.optional),
|
||||
max_base64_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Attachments")({
|
||||
image: Image.pipe(Schema.optional),
|
||||
}) {}
|
||||
16
packages/core/src/config/compaction.ts
Normal file
16
packages/core/src/config/compaction.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export * as ConfigCompaction from "./compaction"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
|
||||
export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
|
||||
turns: NonNegativeInt.pipe(Schema.optional),
|
||||
tokens: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Compaction")({
|
||||
auto: Schema.Boolean.pipe(Schema.optional),
|
||||
prune: Schema.Boolean.pipe(Schema.optional),
|
||||
keep: Keep.pipe(Schema.optional),
|
||||
buffer: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
18
packages/core/src/config/experimental.ts
Normal file
18
packages/core/src/config/experimental.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export * as ConfigExperimental from "./experimental"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Policy as PolicyV2 } from "../policy"
|
||||
|
||||
// Each core domain exports the policy actions it supports. Adding an action to
|
||||
// this union makes it valid in authored config while keeping Policy generic.
|
||||
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
|
||||
|
||||
export class Policy extends Schema.Class<Policy>("ConfigV2.Experimental.Policy")({
|
||||
...PolicyV2.Info.fields,
|
||||
action: PolicyAction,
|
||||
}) {}
|
||||
|
||||
export class Experimental extends Schema.Class<Experimental>("ConfigV2.Experimental")({
|
||||
policies: Policy.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
12
packages/core/src/config/formatter.ts
Normal file
12
packages/core/src/config/formatter.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export * as ConfigFormatter from "./formatter"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("ConfigV2.Formatter.Entry")({
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
command: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
|
||||
18
packages/core/src/config/lsp.ts
Normal file
18
packages/core/src/config/lsp.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export * as ConfigLSP from "./lsp"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Disabled = Schema.Struct({
|
||||
disabled: Schema.Literal(true),
|
||||
})
|
||||
|
||||
export class Server extends Schema.Class<Server>("ConfigV2.LSP.Server")({
|
||||
command: Schema.String.pipe(Schema.Array),
|
||||
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([Disabled, Server])
|
||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
|
||||
36
packages/core/src/config/mcp.ts
Normal file
36
packages/core/src/config/mcp.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export * as ConfigMCP from "./mcp"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
|
||||
type: Schema.Literal("local"),
|
||||
command: Schema.String.pipe(Schema.Array),
|
||||
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
timeout: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class OAuth extends Schema.Class<OAuth>("ConfigV2.MCP.OAuth")({
|
||||
client_id: Schema.String.pipe(Schema.optional),
|
||||
client_secret: Schema.String.pipe(Schema.optional),
|
||||
scope: Schema.String.pipe(Schema.optional),
|
||||
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
|
||||
redirect_uri: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Remote extends Schema.Class<Remote>("ConfigV2.MCP.Remote")({
|
||||
type: Schema.Literal("remote"),
|
||||
url: Schema.String,
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
timeout: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.MCP")({
|
||||
timeout: PositiveInt.pipe(Schema.optional),
|
||||
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
|
||||
}) {}
|
||||
13
packages/core/src/config/plugin.ts
Normal file
13
packages/core/src/config/plugin.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export * as ConfigPlugin from "./plugin"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("ConfigV2.Plugin.Entry")({
|
||||
package: Schema.String,
|
||||
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Plugin = Schema.Union([Schema.String, Entry])
|
||||
export type Plugin = typeof Plugin.Type
|
||||
|
||||
export const Plugins = Plugin.pipe(Schema.Array)
|
||||
66
packages/core/src/config/plugin/agent.ts
Normal file
66
packages/core/src/config/plugin/agent.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-agent"),
|
||||
effect: Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const config = yield* Config.Service
|
||||
const transform = yield* agent.transform()
|
||||
const files = yield* config.get()
|
||||
|
||||
yield* transform((editor) => {
|
||||
const permissions = new Map<AgentV2.ID, PermissionV2.Ruleset>()
|
||||
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.agents ?? {})) {
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
editor.remove(agentID)
|
||||
permissions.delete(agentID)
|
||||
continue
|
||||
}
|
||||
|
||||
editor.update(agentID, (agent) => {
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.options !== undefined) {
|
||||
Object.assign(agent.options.headers, item.options.headers ?? {})
|
||||
Object.assign(agent.options.body, item.options.body ?? {})
|
||||
Object.assign(agent.options.aisdk.provider, item.options.aisdk?.provider ?? {})
|
||||
Object.assign(agent.options.aisdk.request, item.options.aisdk?.request ?? {})
|
||||
}
|
||||
if (item.system !== undefined) agent.system = item.system
|
||||
if (item.description !== undefined) agent.description = item.description
|
||||
if (item.mode !== undefined) agent.mode = item.mode
|
||||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
})
|
||||
|
||||
if (item.permissions !== undefined) {
|
||||
permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...item.permissions])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const global = files.flatMap((file) => file.info.permissions ?? [])
|
||||
for (const current of editor.list()) {
|
||||
editor.update(current.id, (agent) => {
|
||||
agent.permissions.push(...global, ...(permissions.get(current.id) ?? []))
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
95
packages/core/src/config/plugin/provider.ts
Normal file
95
packages/core/src/config/plugin/provider.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Config } from "../../config"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-provider"),
|
||||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Service
|
||||
const transform = yield* catalog.transform()
|
||||
const files = yield* config.get()
|
||||
|
||||
yield* transform((catalog) => {
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = ProviderV2.ID.make(id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.env !== undefined) provider.env = [...item.env]
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
if (item.endpoint !== undefined) provider.endpoint = { ...item.endpoint }
|
||||
if (item.options !== undefined) {
|
||||
Object.assign(provider.options.headers, item.options.headers ?? {})
|
||||
Object.assign(provider.options.body, item.options.body ?? {})
|
||||
Object.assign(provider.options.aisdk.provider, item.options.aisdk?.provider ?? {})
|
||||
Object.assign(provider.options.aisdk.request, item.options.aisdk?.request ?? {})
|
||||
}
|
||||
})
|
||||
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
|
||||
if (config.api_id !== undefined) model.apiID = config.api_id
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.endpoint !== undefined) model.endpoint = { ...config.endpoint }
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.options !== undefined) {
|
||||
Object.assign(model.options.headers, config.options.headers ?? {})
|
||||
Object.assign(model.options.body, config.options.body ?? {})
|
||||
Object.assign(model.options.aisdk.provider, config.options.aisdk?.provider ?? {})
|
||||
Object.assign(model.options.aisdk.request, config.options.aisdk?.request ?? {})
|
||||
if (config.options.variant !== undefined) model.options.variant = config.options.variant
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
for (const variant of config.variants) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
Object.assign(existing.headers, variant.headers ?? {})
|
||||
Object.assign(existing.body, variant.body ?? {})
|
||||
Object.assign(existing.aisdk.provider, variant.aisdk?.provider ?? {})
|
||||
Object.assign(existing.aisdk.request, variant.aisdk?.request ?? {})
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? 0,
|
||||
write: cost.cache?.write ?? 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
62
packages/core/src/config/provider.ts
Normal file
62
packages/core/src/config/provider.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
export * as ConfigProvider from "./provider"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { ModelV2 } from "../model"
|
||||
|
||||
export class Options extends Schema.Class<Options>("ConfigV2.Provider.Options")({
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
aisdk: Schema.Struct({
|
||||
provider: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
request: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
|
||||
read: Schema.Finite.pipe(Schema.optional),
|
||||
write: Schema.Finite.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Int,
|
||||
}).pipe(Schema.optional),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache: Cache.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Limit extends Schema.Class<Limit>("ConfigV2.Model.Limit")({
|
||||
context: Schema.Int.pipe(Schema.optional),
|
||||
input: Schema.Int.pipe(Schema.optional),
|
||||
output: Schema.Int.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Model extends Schema.Class<Model>("ConfigV2.Model")({
|
||||
api_id: ModelV2.ID.pipe(Schema.optional),
|
||||
family: ModelV2.Family.pipe(Schema.optional),
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
endpoint: ProviderV2.Endpoint.pipe(Schema.optional),
|
||||
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
|
||||
options: Schema.Struct({
|
||||
...Options.fields,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
variants: Schema.Struct({
|
||||
id: ModelV2.VariantID,
|
||||
...Options.fields,
|
||||
}).pipe(Schema.Array, Schema.optional),
|
||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
limit: Limit.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Provider")({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
env: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
endpoint: ProviderV2.Endpoint.pipe(Schema.optional),
|
||||
options: Options.pipe(Schema.optional),
|
||||
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
|
||||
}) {}
|
||||
17
packages/core/src/config/reference.ts
Normal file
17
packages/core/src/config/reference.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export * as ConfigReference from "./reference"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Git extends Schema.Class<Git>("ConfigV2.Reference.Git")({
|
||||
repository: Schema.String,
|
||||
branch: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Local extends Schema.Class<Local>("ConfigV2.Reference.Local")({
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([Schema.String, Git, Local])
|
||||
export type Entry = typeof Entry.Type
|
||||
|
||||
export const Info = Schema.Record(Schema.String, Entry)
|
||||
9
packages/core/src/config/tool-output.ts
Normal file
9
packages/core/src/config/tool-output.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export * as ConfigToolOutput from "./tool-output"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.ToolOutput")({
|
||||
max_lines: PositiveInt.pipe(Schema.optional),
|
||||
max_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
7
packages/core/src/config/watcher.ts
Normal file
7
packages/core/src/config/watcher.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export * as ConfigWatcher from "./watcher"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Watcher")({
|
||||
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
|
|
@ -128,7 +128,7 @@ export const layer = Layer.effect(
|
|||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(definition.version === undefined ? {} : { version: definition.version }),
|
||||
...(location ? { location } : {}),
|
||||
...(location ? { location: { directory: location.directory, workspaceID: location.workspaceID } } : {}),
|
||||
data,
|
||||
} as Payload<D>
|
||||
return yield* publishEvent(event)
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@ import { Layer, LayerMap } from "effect"
|
|||
import { Location } from "./location"
|
||||
import { Catalog } from "./catalog"
|
||||
import { PluginBoot } from "./plugin/boot"
|
||||
import { Policy } from "./policy"
|
||||
import { Config } from "./config"
|
||||
|
||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||
lookup: (ref: Location.Ref) =>
|
||||
Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
|
||||
Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]),
|
||||
),
|
||||
idleTimeToLive: "5 minutes",
|
||||
lookup: (ref: Location.Ref) => {
|
||||
const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer).pipe(
|
||||
Layer.provideMerge(Policy.defaultLayer),
|
||||
Layer.provideMerge(Location.defaultLayer(ref)),
|
||||
)
|
||||
return result
|
||||
},
|
||||
idleTimeToLive: "60 minutes",
|
||||
dependencies: [],
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,40 @@
|
|||
import { Context, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "./project"
|
||||
import { AbsolutePath } from "./schema"
|
||||
|
||||
export * as Location from "./location"
|
||||
|
||||
export const Ref = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
directory: AbsolutePath,
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "Location.Ref" })
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Ref>()("@opencode/Location") {}
|
||||
export interface Interface {
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: string
|
||||
readonly project: {
|
||||
readonly id: Project.ID
|
||||
readonly directory: AbsolutePath
|
||||
}
|
||||
readonly vcs?: Project.Vcs
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
||||
|
||||
export const layer = (ref: Ref) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const resolved = yield* project.resolve(ref.directory)
|
||||
return Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: resolved.id, directory: resolved.directory },
|
||||
vcs: resolved.vcs,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = (ref: Ref) => layer(ref).pipe(Layer.provide(Project.defaultLayer))
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export const Cost = Schema.Struct({
|
|||
export const Ref = Schema.Struct({
|
||||
id: ID,
|
||||
providerID: ProviderV2.ID,
|
||||
variant: VariantID,
|
||||
variant: VariantID.pipe(Schema.optional),
|
||||
})
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
|
|
|
|||
|
|
@ -2,17 +2,26 @@ export * as PluginV2 from "./plugin"
|
|||
|
||||
import { createDraft, finishDraft, type Draft } from "immer"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect"
|
||||
import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
|
||||
import type { ModelV2 } from "./model"
|
||||
import type { AgentV2 } from "./agent"
|
||||
import type { Catalog } from "./catalog"
|
||||
import { EventV2 } from "./event"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Event = {
|
||||
Added: EventV2.define({
|
||||
type: "plugin.added",
|
||||
schema: {
|
||||
id: ID,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
type HookSpec = {
|
||||
"catalog.transform": {
|
||||
input: Catalog.Context
|
||||
input: Catalog.Editor
|
||||
output: {}
|
||||
}
|
||||
"account.switched": {
|
||||
|
|
@ -43,27 +52,6 @@ type HookSpec = {
|
|||
sdk?: any
|
||||
}
|
||||
}
|
||||
"agent.update": {
|
||||
input: {}
|
||||
output: {
|
||||
agent: AgentV2.Info
|
||||
cancel: boolean
|
||||
}
|
||||
}
|
||||
"agent.remove": {
|
||||
input: {
|
||||
agent: AgentV2.Info
|
||||
}
|
||||
output: {
|
||||
cancel: boolean
|
||||
}
|
||||
}
|
||||
"agent.default": {
|
||||
input: {}
|
||||
output: {
|
||||
agent?: AgentV2.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Hooks = {
|
||||
|
|
@ -93,7 +81,6 @@ export interface Interface {
|
|||
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
|
||||
}) => Effect.Effect<void, never, never>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly added: () => Stream.Stream<ID>
|
||||
readonly triggerFor: <Name extends keyof Hooks>(
|
||||
id: ID,
|
||||
name: Name,
|
||||
|
|
@ -117,9 +104,7 @@ export const layer = Layer.effect(
|
|||
hooks: HookFunctions
|
||||
scope: Scope.Closeable
|
||||
}[] = []
|
||||
const added = yield* PubSub.unbounded<ID>()
|
||||
|
||||
yield* Effect.addFinalizer(() => PubSub.shutdown(added))
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
const svc = Service.of({
|
||||
add: Effect.fn("Plugin.add")(function* (input) {
|
||||
|
|
@ -135,9 +120,8 @@ export const layer = Layer.effect(
|
|||
scope,
|
||||
},
|
||||
]
|
||||
yield* PubSub.publish(added, input.id)
|
||||
yield* events.publish(Event.Added, { id: input.id })
|
||||
}),
|
||||
added: () => Stream.fromPubSub(added),
|
||||
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
|
||||
return yield* svc.triggerFor(ID.make("*"), name, input, output)
|
||||
}),
|
||||
|
|
@ -185,7 +169,7 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer))
|
||||
|
||||
// opencode
|
||||
// sdcok
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const AccountPlugin = PluginV2.define({
|
|||
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie)
|
||||
if (!account) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ import { Context, Deferred, Effect, Layer } from "effect"
|
|||
import { AccountV2 } from "../account"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Config } from "../config"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { EventV2 } from "../event"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { AccountPlugin } from "./account"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { EnvPlugin } from "./env"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
|
|
@ -15,7 +18,7 @@ import { ProviderPlugins } from "./provider"
|
|||
type Plugin = {
|
||||
id: PluginV2.ID
|
||||
effect: PluginV2.Effect<
|
||||
Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service
|
||||
Catalog.Service | AccountV2.Service | AgentV2.Service | Npm.Service | EventV2.Service | PluginV2.Service | Config.Service
|
||||
>
|
||||
}
|
||||
|
||||
|
|
@ -28,10 +31,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const accounts = yield* AccountV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
const events = yield* EventV2.Service
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
|
@ -41,8 +45,9 @@ export const layer = Layer.effect(
|
|||
id: input.id,
|
||||
effect: input.effect.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(AgentV2.Service, agent),
|
||||
Effect.provideService(AccountV2.Service, accounts),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
|
|
@ -57,6 +62,8 @@ export const layer = Layer.effect(
|
|||
yield* add(item)
|
||||
}
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
}).pipe(Effect.withSpan("PluginBoot.boot"))
|
||||
|
||||
yield* boot.pipe(
|
||||
|
|
@ -72,10 +79,11 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AgentV2.defaultLayer),
|
||||
Layer.provide(Catalog.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(PluginV2.defaultLayer),
|
||||
Layer.provide(AccountV2.defaultLayer),
|
||||
Layer.provide(AgentV2.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const EnvPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
const key = item.provider.env.find((env) => process.env[env])
|
||||
if (!key) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
|
|
|
|||
|
|
@ -57,10 +57,10 @@ export const ModelsDevPlugin = PluginV2.define({
|
|||
const modelsDev = yield* ModelsDev.Service
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const load = yield* catalog.loader()
|
||||
const transform = yield* catalog.transform()
|
||||
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
|
||||
const data = yield* modelsDev.get()
|
||||
yield* load((catalog) => {
|
||||
yield* transform((catalog) => {
|
||||
for (const item of Object.values(data)) {
|
||||
const providerID = ProviderV2.ID.make(item.id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export const AmazonBedrockPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/amazon-bedrock") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const AnthropicPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/anthropic") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const AzurePlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/azure") continue
|
||||
const configured = item.provider.options.aisdk.provider.resourceName
|
||||
|
|
@ -58,7 +58,7 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
|
|||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
if (!resourceName) return
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (!item.provider.id.includes("azure-cognitive-services")) continue
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const CerebrasPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (ctx) {
|
||||
for (const item of ctx.data) {
|
||||
for (const item of ctx.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/cerebras") continue
|
||||
ctx.provider.update(item.provider.id, (provider) => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.data.find((record) => record.provider.id === providerID)
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (provider.endpoint.type !== "aisdk") return
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export const GithubCopilotPlugin = PluginV2.define({
|
|||
: evt.sdk.chat(evt.model.apiID)
|
||||
}),
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.githubCopilot)
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export const GoogleVertexPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (
|
||||
item.provider.endpoint.package !== "@ai-sdk/google-vertex" &&
|
||||
|
|
@ -110,7 +110,7 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue
|
||||
const project =
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const KiloPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.endpoint.url !== "https://api.kilo.ai/api/gateway") continue
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const LLMGatewayPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.enabled === false) continue
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const NvidiaPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.endpoint.url !== "https://integrate.api.nvidia.com/v1") continue
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const OpenAIPlugin = PluginV2.define({
|
|||
evt.language = evt.sdk.responses(evt.model.apiID)
|
||||
}),
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai") continue
|
||||
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const OpencodePlugin = PluginV2.define({
|
|||
let hasKey = false
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.opencode)
|
||||
const item = evt.provider.get(ProviderV2.ID.opencode)
|
||||
if (!item) return
|
||||
hasKey = Boolean(
|
||||
process.env.OPENCODE_API_KEY ||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const OpenRouterPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@openrouter/ai-sdk-provider") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const VercelPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/vercel") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const ZenmuxPlugin = PluginV2.define({
|
|||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.endpoint.url !== "https://zenmux.ai/api/v1") continue
|
||||
|
|
|
|||
44
packages/core/src/policy.ts
Normal file
44
packages/core/src/policy.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export * as Policy from "./policy"
|
||||
|
||||
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
import { Location } from "./location"
|
||||
|
||||
export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
|
||||
export type Effect = typeof Effect.Type
|
||||
|
||||
export class Info extends Schema.Class<Info>("Policy.Info")({
|
||||
action: Schema.String,
|
||||
effect: Effect,
|
||||
resource: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (statements: Info[]) => EffectRuntime.Effect<void>
|
||||
readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect<Effect>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Policy") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
EffectRuntime.gen(function* () {
|
||||
let statements: Info[] = []
|
||||
yield* Location.Service
|
||||
|
||||
return Service.of({
|
||||
load: EffectRuntime.fn("Policy.load")(function* (input) {
|
||||
statements = input
|
||||
}),
|
||||
evaluate: EffectRuntime.fn("Policy.evaluate")(function* (action, resource, fallback) {
|
||||
return (
|
||||
statements.findLast(
|
||||
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
|
||||
)?.effect ?? fallback
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
|
@ -25,7 +25,6 @@ export type Vcs = typeof Vcs.Type
|
|||
|
||||
export class Info extends Schema.Class<Info>("Project.Info")({
|
||||
id: ID,
|
||||
vcs: Schema.optional(Vcs),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
|
|
@ -105,7 +104,7 @@ export const layer = Layer.effect(
|
|||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const repo = yield* git.find(input)
|
||||
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
|
||||
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
|
||||
|
||||
const previous = yield* cached(repo.store)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
|
|
|
|||
67
packages/core/src/state.ts
Normal file
67
packages/core/src/state.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
export * as State from "./state"
|
||||
|
||||
import { Effect, Scope, Semaphore } from "effect"
|
||||
import { createDraft, finishDraft, type Draft, type Objectish } from "immer"
|
||||
|
||||
export type Transform<Editor> = (editor: Editor) => void
|
||||
export type MakeEditor<State extends Objectish, Editor> = (draft: Draft<State>) => Editor
|
||||
|
||||
export interface Options<State extends Objectish, Editor> {
|
||||
readonly initial: () => State
|
||||
readonly editor: MakeEditor<State, Editor>
|
||||
/** Completes every committed edit; reason identifies exceptional update origins. */
|
||||
readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Interface<State extends Objectish, Editor> {
|
||||
readonly get: () => State
|
||||
readonly transform: () => Effect.Effect<
|
||||
(transform: Transform<Editor>) => Effect.Effect<void>,
|
||||
never,
|
||||
Scope.Scope
|
||||
>
|
||||
readonly update: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
|
||||
let state = options.initial()
|
||||
let transforms: { update: Transform<Editor> }[] = []
|
||||
const semaphore = Semaphore.makeUnsafe(1)
|
||||
|
||||
const commit = Effect.fn("State.commit")(function* (draft: Draft<State>, reason?: string) {
|
||||
const api = options.editor(draft)
|
||||
if (options.finalize) yield* options.finalize(api, reason)
|
||||
state = finishDraft(draft) as State
|
||||
})
|
||||
|
||||
const rebuild = Effect.fn("State.rebuild")(function* () {
|
||||
const draft = createDraft(options.initial())
|
||||
const api = options.editor(draft)
|
||||
for (const transform of transforms) transform.update(api)
|
||||
yield* commit(draft)
|
||||
}, semaphore.withPermit)
|
||||
|
||||
return {
|
||||
get: () => state,
|
||||
transform: Effect.fn("State.transform")(function* () {
|
||||
const transform = { update: (_editor: Editor) => {} }
|
||||
transforms = [...transforms, transform]
|
||||
const scope = yield* Scope.Scope
|
||||
yield* Scope.addFinalizer(
|
||||
scope,
|
||||
Effect.sync(() => {
|
||||
transforms = transforms.filter((item) => item !== transform)
|
||||
}).pipe(Effect.andThen(rebuild())),
|
||||
)
|
||||
return Effect.fnUntraced(function* (update: Transform<Editor>) {
|
||||
transform.update = update
|
||||
yield* rebuild()
|
||||
})
|
||||
}),
|
||||
update: Effect.fn("State.update")(function* (update, reason) {
|
||||
const draft = createDraft(state)
|
||||
yield* update(options.editor(draft))
|
||||
yield* commit(draft, reason)
|
||||
}, semaphore.withPermit),
|
||||
}
|
||||
}
|
||||
|
|
@ -19,12 +19,11 @@ const it = testEffect(PluginV2.defaultLayer)
|
|||
function context(
|
||||
records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
|
||||
updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>,
|
||||
): Catalog.Context {
|
||||
): Catalog.Editor {
|
||||
return {
|
||||
data: records,
|
||||
updateProvider: (providerID, fn) => context(records, updates).provider.update(providerID, fn),
|
||||
updateModel: (providerID, modelID, fn) => context(records, updates).model.update(providerID, modelID, fn),
|
||||
provider: {
|
||||
list: () => records,
|
||||
get: (providerID) => records.find((item) => item.provider.id === providerID),
|
||||
update: (providerID, fn) => {
|
||||
const record = records.find((item) => item.provider.id === providerID)
|
||||
const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
|
||||
|
|
@ -45,8 +44,13 @@ function context(
|
|||
},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: () => {},
|
||||
remove: () => {},
|
||||
default: {
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -192,7 +196,7 @@ describe("AccountV2", () => {
|
|||
]
|
||||
const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
|
||||
const catalog = Catalog.Service.of({
|
||||
loader: () => Effect.die("unexpected catalog.loader"),
|
||||
transform: () => Effect.die("unexpected catalog.transform"),
|
||||
provider: {
|
||||
get: () => Effect.die("unexpected provider.get"),
|
||||
all: () => Effect.succeed([]),
|
||||
|
|
@ -203,7 +207,6 @@ describe("AccountV2", () => {
|
|||
all: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed([]),
|
||||
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
|
||||
setDefault: () => Effect.die("unexpected model.setDefault"),
|
||||
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
|
||||
},
|
||||
})
|
||||
|
|
|
|||
105
packages/core/test/agent.test.ts
Normal file
105
packages/core/test/agent.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AgentV2.defaultLayer)
|
||||
|
||||
describe("AgentV2", () => {
|
||||
it.effect("starts without agents", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
|
||||
expect(yield* agent.all()).toEqual([])
|
||||
expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("materializes replayable agent transforms", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("reviewer")
|
||||
const transform = yield* agent.transform()
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.update(id, (info) => {
|
||||
info.description = "Reviews code"
|
||||
info.mode = "subagent"
|
||||
}),
|
||||
)
|
||||
|
||||
expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" })
|
||||
expect((yield* agent.all()).map((info) => info.id)).toEqual([id])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebuilds state when a transform is replaced", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("reviewer")
|
||||
const transform = yield* agent.transform()
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.update(id, (info) => {
|
||||
info.description = "Old description"
|
||||
info.hidden = true
|
||||
}),
|
||||
)
|
||||
yield* transform((editor) =>
|
||||
editor.update(id, (info) => {
|
||||
info.description = "New description"
|
||||
}),
|
||||
)
|
||||
|
||||
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes a transform contribution when its scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("scoped")
|
||||
const scope = yield* Scope.make()
|
||||
const transform = yield* agent.transform().pipe(Scope.provide(scope))
|
||||
|
||||
yield* transform((editor) => editor.update(id, () => {}))
|
||||
expect(yield* agent.get(id)).toBeDefined()
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* agent.get(id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies direct agent updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("build")
|
||||
|
||||
yield* agent.update((editor) =>
|
||||
Effect.sync(() =>
|
||||
editor.update(id, (info) => {
|
||||
info.mode = "primary"
|
||||
info.hidden = true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* agent.get(id)).toMatchObject({ id, mode: "primary", hidden: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates agents with runtime defaults and supports direct removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("custom")
|
||||
|
||||
yield* agent.update((editor) => Effect.sync(() => editor.update(id, () => {})))
|
||||
expect(yield* agent.get(id)).toEqual(
|
||||
AgentV2.Info.empty(id),
|
||||
)
|
||||
|
||||
yield* agent.update((editor) => Effect.sync(() => editor.remove(id)))
|
||||
expect(yield* agent.get(id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -5,14 +5,21 @@ import { EventV2 } from "@opencode-ai/core/event"
|
|||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
const it = testEffect(
|
||||
Catalog.layer.pipe(
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(PluginV2.defaultLayer),
|
||||
Layer.provideMerge(Policy.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
),
|
||||
)
|
||||
|
|
@ -22,9 +29,9 @@ describe("CatalogV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const load = yield* catalog.loader()
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* load((catalog) =>
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
|
|
@ -48,9 +55,9 @@ describe("CatalogV2", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
const load = yield* catalog.loader()
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* load((catalog) => {
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
|
|
@ -77,9 +84,9 @@ describe("CatalogV2", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
const load = yield* catalog.loader()
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* load((catalog) => {
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
|
|
@ -104,14 +111,14 @@ describe("CatalogV2", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const seen: unknown[] = []
|
||||
const load = yield* catalog.loader()
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("test"),
|
||||
effect: Effect.succeed({
|
||||
"catalog.transform": (evt) =>
|
||||
Effect.sync(() => {
|
||||
const item = evt.data.find((record) => record.provider.id === providerID)
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
seen.push(item.provider.endpoint.type)
|
||||
if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url)
|
||||
|
|
@ -119,7 +126,7 @@ describe("CatalogV2", () => {
|
|||
}),
|
||||
}),
|
||||
})
|
||||
yield* load((catalog) =>
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
provider.options.aisdk.provider.baseURL = "https://provider.example.com"
|
||||
|
|
@ -135,9 +142,9 @@ describe("CatalogV2", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const load = yield* catalog.loader()
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* load((catalog) =>
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = "Before"
|
||||
}),
|
||||
|
|
@ -164,9 +171,9 @@ describe("CatalogV2", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
const load = yield* catalog.loader()
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* load((catalog) => {
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.options.headers.provider = "provider"
|
||||
provider.options.headers.shared = "provider"
|
||||
|
|
@ -194,9 +201,9 @@ describe("CatalogV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const load = yield* catalog.loader()
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* load((catalog) => {
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
|
|
@ -212,13 +219,44 @@ describe("CatalogV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("uses a transform-provided default model until that transform is replaced", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const old = ModelV2.ID.make("old")
|
||||
const newest = ModelV2.ID.make("new")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
const models = (catalog: Catalog.Editor) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
catalog.model.update(providerID, old, (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(1000)
|
||||
})
|
||||
catalog.model.update(providerID, newest, (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(2000)
|
||||
})
|
||||
}
|
||||
|
||||
yield* transform((catalog) => {
|
||||
models(catalog)
|
||||
catalog.model.default.set(providerID, old)
|
||||
})
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(old)
|
||||
|
||||
yield* transform(models)
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(newest)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("small model prefers small keyword candidates before cost scoring", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const load = yield* catalog.loader()
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* load((catalog) => {
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
|
|
@ -237,4 +275,23 @@ describe("CatalogV2", () => {
|
|||
expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes providers denied by policy after loading", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const policy = yield* Policy.Service
|
||||
const providerID = ProviderV2.ID.make("blocked")
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
|
||||
})
|
||||
|
||||
expect(yield* catalog.provider.all()).toEqual([])
|
||||
expect(yield* catalog.model.all()).toEqual([])
|
||||
expect(yield* catalog.provider.get(providerID).pipe(Effect.option)).toEqual(Option.none())
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
186
packages/core/test/config/agent.test.ts
Normal file
186
packages/core/test/config/agent.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AgentV2.defaultLayer)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("applies global permissions between built-in and agent-specific permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const build = AgentV2.ID.make("build")
|
||||
const defaults = yield* agents.transform()
|
||||
|
||||
yield* defaults((editor) =>
|
||||
editor.update(build, (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" })
|
||||
}),
|
||||
)
|
||||
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
get: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
permissions: [{ permission: "bash", pattern: "*", action: "ask" }],
|
||||
agents: {
|
||||
build: {
|
||||
permissions: [{ permission: "bash", pattern: "git *", action: "allow" }],
|
||||
},
|
||||
reviewer: {
|
||||
model: "openrouter/openai/gpt-5",
|
||||
description: "Review changes",
|
||||
mode: "subagent",
|
||||
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
|
||||
},
|
||||
removed: { description: "Removed later" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
agents: {
|
||||
reviewer: { variant: "high", hidden: true },
|
||||
removed: { disabled: true },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
)
|
||||
|
||||
const buildAgent = yield* agents.get(build)
|
||||
if (!buildAgent) throw new Error("expected configured build agent")
|
||||
expect(buildAgent.permissions).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "bash", pattern: "git *", action: "allow" },
|
||||
])
|
||||
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow")
|
||||
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask")
|
||||
|
||||
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
|
||||
if (!reviewer) throw new Error("expected configured reviewer agent")
|
||||
expect(reviewer).toMatchObject({
|
||||
description: "Review changes",
|
||||
mode: "subagent",
|
||||
hidden: true,
|
||||
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
|
||||
})
|
||||
expect(reviewer.permissions).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "edit", pattern: "*", action: "deny" },
|
||||
])
|
||||
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
get: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
agents: {
|
||||
reviewer: {
|
||||
model: "anthropic/claude-sonnet",
|
||||
system: "Review carefully.",
|
||||
description: "Reviews changes",
|
||||
mode: "subagent",
|
||||
hidden: true,
|
||||
color: "warning",
|
||||
steps: 12,
|
||||
options: {
|
||||
headers: { first: "one", shared: "first" },
|
||||
body: { enabled: true },
|
||||
aisdk: { provider: { profile: "review" }, request: { effort: "medium" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
agents: {
|
||||
reviewer: {
|
||||
options: {
|
||||
headers: { shared: "last", second: "two" },
|
||||
body: { retries: 2 },
|
||||
aisdk: { request: { effort: "high" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
)
|
||||
|
||||
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
|
||||
if (!reviewer) throw new Error("expected configured reviewer agent")
|
||||
expect(reviewer).toMatchObject({
|
||||
system: "Review carefully.",
|
||||
description: "Reviews changes",
|
||||
mode: "subagent",
|
||||
hidden: true,
|
||||
color: "warning",
|
||||
steps: 12,
|
||||
model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
|
||||
})
|
||||
expect(reviewer.options).toEqual({
|
||||
headers: { first: "one", shared: "last", second: "two" },
|
||||
body: { enabled: true, retries: 2 },
|
||||
aisdk: { provider: { profile: "review" }, request: { effort: "high" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes a built-in agent disabled by configuration", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const build = AgentV2.ID.make("build")
|
||||
const defaults = yield* agents.transform()
|
||||
yield* defaults((editor) => editor.update(build, () => {}))
|
||||
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
get: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({ agents: { build: { disabled: true } } }),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
)
|
||||
|
||||
expect(yield* agents.get(build)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
451
packages/core/test/config/config.test.ts
Normal file
451
packages/core/test/config/config.test.ts
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
function testLayer(
|
||||
directory: string,
|
||||
globalDirectory = path.join(directory, "global"),
|
||||
projectDirectory = directory,
|
||||
vcs?: Project.Vcs,
|
||||
) {
|
||||
return Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Global.layerWith({ config: globalDirectory })),
|
||||
Layer.provideMerge(Policy.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(projectDirectory), vcs },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const provider = {
|
||||
endpoint: { type: "unknown" },
|
||||
options: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
models: {},
|
||||
}
|
||||
|
||||
describe("Config", () => {
|
||||
it.live("returns an empty configuration when directory files do not exist", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = yield* config.get()
|
||||
|
||||
expect(documents).toEqual([])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads JSON and JSONC files from lowest to highest priority", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "config.json"),
|
||||
JSON.stringify({ $schema: "base", providers: { base: provider } }),
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({ $schema: "middle", providers: { middle: provider } }),
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.jsonc"),
|
||||
`{
|
||||
// Later global files override scalar fields while retaining providers.
|
||||
"$schema": "last",
|
||||
"providers": { "last": ${JSON.stringify(provider)} },
|
||||
}`,
|
||||
),
|
||||
]),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = yield* config.get()
|
||||
|
||||
expect(documents).toHaveLength(3)
|
||||
expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
|
||||
expect(documents[0]).toBeInstanceOf(Config.Loaded)
|
||||
expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe(
|
||||
path.join(tmp.path, "config.json"),
|
||||
)
|
||||
expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
|
||||
)
|
||||
expect((yield* config.get()).map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("accepts $schema metadata without writing it into config files", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "opencode.json")
|
||||
const contents = JSON.stringify({
|
||||
shell: "/bin/zsh",
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
|
||||
providers: { local: provider },
|
||||
})
|
||||
yield* Effect.promise(() => fs.writeFile(file, contents))
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = yield* config.get()
|
||||
|
||||
expect(documents[0]?.info.$schema).toBeUndefined()
|
||||
expect(documents[0]?.info.shell).toBe("/bin/zsh")
|
||||
expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
|
||||
effect: "deny",
|
||||
action: "provider.use",
|
||||
resource: "openai",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads supported scalar and resource configuration", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
shell: "/bin/bash",
|
||||
model: "anthropic/claude",
|
||||
autoupdate: "notify",
|
||||
share: "disabled",
|
||||
enterprise: { url: "https://share.example.com" },
|
||||
username: "test-user",
|
||||
permissions: [
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "bash", pattern: "git status", action: "allow" },
|
||||
],
|
||||
agents: {
|
||||
reviewer: {
|
||||
model: "openrouter/openai/gpt-5",
|
||||
variant: "high",
|
||||
options: {
|
||||
headers: { "x-agent": "reviewer" },
|
||||
aisdk: { request: { reasoningEffort: "high" } },
|
||||
},
|
||||
description: "Review changes for correctness",
|
||||
system: "Find regressions.",
|
||||
mode: "subagent",
|
||||
hidden: false,
|
||||
color: "warning",
|
||||
steps: 12,
|
||||
disabled: false,
|
||||
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
|
||||
},
|
||||
},
|
||||
snapshots: false,
|
||||
watcher: { ignore: ["node_modules/**", "dist/**", ".git"] },
|
||||
formatter: { prettier: { disabled: true }, custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] } },
|
||||
lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
|
||||
attachments: { image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 } },
|
||||
tool_output: { max_lines: 1000, max_bytes: 32768 },
|
||||
mcp: {
|
||||
timeout: 5000,
|
||||
servers: {
|
||||
local: {
|
||||
type: "local",
|
||||
command: ["node", "./mcp/server.js"],
|
||||
environment: { API_KEY: "secret" },
|
||||
disabled: false,
|
||||
timeout: 10000,
|
||||
},
|
||||
remote: {
|
||||
type: "remote",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
compaction: {
|
||||
auto: true,
|
||||
prune: false,
|
||||
keep: { turns: 3, tokens: 2000 },
|
||||
buffer: 10000,
|
||||
},
|
||||
skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
|
||||
instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"],
|
||||
references: {
|
||||
local: { path: "../library" },
|
||||
sdk: { repository: "github.com/example/sdk", branch: "main" },
|
||||
shorthand: "github.com/example/docs",
|
||||
},
|
||||
plugins: [
|
||||
"opencode-helicone-session",
|
||||
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = yield* config.get()
|
||||
|
||||
expect(documents).toHaveLength(1)
|
||||
expect(documents[0]?.info.shell).toBe("/bin/bash")
|
||||
expect(documents[0]?.info.model).toBe("anthropic/claude")
|
||||
expect(documents[0]?.info.autoupdate).toBe("notify")
|
||||
expect(documents[0]?.info.share).toBe("disabled")
|
||||
expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
|
||||
expect(documents[0]?.info.username).toBe("test-user")
|
||||
expect(documents[0]?.info.permissions).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "bash", pattern: "git status", action: "allow" },
|
||||
])
|
||||
expect(documents[0]?.info.agents?.reviewer).toEqual({
|
||||
model: "openrouter/openai/gpt-5",
|
||||
variant: "high",
|
||||
options: {
|
||||
headers: { "x-agent": "reviewer" },
|
||||
aisdk: { request: { reasoningEffort: "high" } },
|
||||
},
|
||||
description: "Review changes for correctness",
|
||||
system: "Find regressions.",
|
||||
mode: "subagent",
|
||||
hidden: false,
|
||||
color: "warning",
|
||||
steps: 12,
|
||||
disabled: false,
|
||||
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
|
||||
})
|
||||
expect(documents[0]?.info.snapshots).toBe(false)
|
||||
expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
|
||||
expect(documents[0]?.info.formatter).toEqual({
|
||||
prettier: { disabled: true },
|
||||
custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
|
||||
})
|
||||
expect(documents[0]?.info.lsp).toEqual({
|
||||
typescript: { disabled: true },
|
||||
custom: { command: ["custom-lsp"], extensions: [".foo"] },
|
||||
})
|
||||
expect(documents[0]?.info.attachments).toEqual({
|
||||
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
|
||||
})
|
||||
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
|
||||
expect(documents[0]?.info.mcp).toEqual({
|
||||
timeout: 5000,
|
||||
servers: {
|
||||
local: {
|
||||
type: "local",
|
||||
command: ["node", "./mcp/server.js"],
|
||||
environment: { API_KEY: "secret" },
|
||||
disabled: false,
|
||||
timeout: 10000,
|
||||
},
|
||||
remote: {
|
||||
type: "remote",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
oauth: { client_id: "client", scope: "read write", callback_port: 19876 },
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(documents[0]?.info.compaction).toEqual({
|
||||
auto: true,
|
||||
prune: false,
|
||||
keep: { turns: 3, tokens: 2000 },
|
||||
buffer: 10000,
|
||||
})
|
||||
expect(documents[0]?.info.skills).toEqual([
|
||||
"./skills",
|
||||
"~/shared-skills",
|
||||
"https://example.com/.well-known/skills/",
|
||||
])
|
||||
expect(documents[0]?.info.instructions).toEqual([
|
||||
"CONTRIBUTING.md",
|
||||
".cursor/rules/*.md",
|
||||
"https://example.com/shared-rules.md",
|
||||
])
|
||||
expect(documents[0]?.info.references).toEqual({
|
||||
local: { path: "../library" },
|
||||
sdk: { repository: "github.com/example/sdk", branch: "main" },
|
||||
shorthand: "github.com/example/docs",
|
||||
})
|
||||
expect(documents[0]?.info.plugins).toEqual([
|
||||
"opencode-helicone-session",
|
||||
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
|
||||
])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores invalid files while loading valid config values", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })),
|
||||
fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"),
|
||||
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })),
|
||||
]),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = yield* config.get()
|
||||
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads policy statements in reverse config order", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(global, "opencode.json"),
|
||||
JSON.stringify({ experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] } }),
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({ experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] } }),
|
||||
)
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
|
||||
}).pipe(Effect.provide(testLayer(tmp.path, global)))
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const root = path.join(tmp.path, "repo")
|
||||
const parent = path.join(root, "packages")
|
||||
const directory = path.join(parent, "app")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await fs.mkdir(path.join(root, ".opencode"), { recursive: true })
|
||||
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "outside" })),
|
||||
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
|
||||
fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
|
||||
fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
|
||||
fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })),
|
||||
fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
|
||||
fs.writeFile(
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
JSON.stringify({ $schema: "directory-dot" }),
|
||||
),
|
||||
])
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const directories = yield* config.directories()
|
||||
const documents = yield* config.get()
|
||||
|
||||
expect(directories).toEqual([
|
||||
AbsolutePath.make(global),
|
||||
AbsolutePath.make(path.join(root, ".opencode")),
|
||||
AbsolutePath.make(path.join(directory, ".opencode")),
|
||||
])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual([
|
||||
"global",
|
||||
"root",
|
||||
"parent",
|
||||
"directory",
|
||||
"root-dot",
|
||||
"directory-dot",
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(directory, global, root, {
|
||||
type: "git",
|
||||
store: AbsolutePath.make(path.join(root, ".git")),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
131
packages/core/test/config/provider.test.ts
Normal file
131
packages/core/test/config/provider.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it } from "../plugin/provider-helper"
|
||||
|
||||
function options(headers: Record<string, string>, variant?: string) {
|
||||
return {
|
||||
headers,
|
||||
variant,
|
||||
}
|
||||
}
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("loads configured providers and applies later model overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
get: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
endpoint: { type: "unknown" },
|
||||
options: options({ first: "first", shared: "first" }),
|
||||
models: {
|
||||
chat: {
|
||||
name: "First",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
disabled: true,
|
||||
limit: { context: 100, output: 50 },
|
||||
cost: { input: 1, output: 2 },
|
||||
options: options({ first: "first", shared: "first" }, "retained"),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { first: "first", shared: "first" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
endpoint: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
|
||||
options: options({ last: "last", shared: "last" }),
|
||||
models: {
|
||||
chat: {
|
||||
api_id: "api-chat",
|
||||
name: "Last",
|
||||
limit: { output: 75 },
|
||||
options: options({ last: "last", shared: "last" }),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { last: "last", shared: "last" },
|
||||
},
|
||||
{
|
||||
id: "slow",
|
||||
headers: { slow: "slow" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: { name: "Renamed" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
),
|
||||
})
|
||||
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
|
||||
expect(provider.enabled).toEqual({ via: "custom", data: {} })
|
||||
expect(provider.endpoint).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
||||
expect(provider.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.apiID).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
|
||||
expect(model.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.options.variant).toBe("retained")
|
||||
expect(model.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("fast"),
|
||||
ModelV2.VariantID.make("slow"),
|
||||
])
|
||||
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -2,11 +2,13 @@ import { describe, expect } from "bun:test"
|
|||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({ directory: "project", workspaceID: "workspace" }),
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })),
|
||||
)
|
||||
const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer)))
|
||||
const itWithoutLocation = testEffect(EventV2.layer)
|
||||
|
|
@ -46,7 +48,7 @@ describe("EventV2", () => {
|
|||
expect(event.type).toBe("test.message")
|
||||
expect(event).not.toHaveProperty("version")
|
||||
expect(event.data).toEqual({ text: "hello" })
|
||||
expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" })
|
||||
expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
12
packages/core/test/fixture/location.ts
Normal file
12
packages/core/test/fixture/location.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
||||
export function location(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
|
||||
return {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: input.projectDirectory ?? ref.directory },
|
||||
vcs: input.vcs,
|
||||
} satisfies Location.Interface
|
||||
}
|
||||
38
packages/core/test/location.test.ts
Normal file
38
packages/core/test/location.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "workspace" }
|
||||
const projectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
resolve: () =>
|
||||
Effect.succeed({
|
||||
id: Project.ID.make("project"),
|
||||
directory: AbsolutePath.make("/repo"),
|
||||
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
|
||||
}),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer)))
|
||||
|
||||
describe("Location", () => {
|
||||
it.effect("resolves the current project and vcs information", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
|
||||
expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app"))
|
||||
expect(location.workspaceID).toBe("workspace")
|
||||
expect(location.project.id).toBe(Project.ID.make("project"))
|
||||
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
|
||||
expect(location.vcs).toEqual({
|
||||
type: "git",
|
||||
store: AbsolutePath.make("/repo/.git"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
23
packages/core/test/model.test.ts
Normal file
23
packages/core/test/model.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
||||
const decode = Schema.decodeUnknownSync(ModelV2.Ref)
|
||||
|
||||
describe("ModelV2.Ref", () => {
|
||||
test("accepts a model selection without a variant", () => {
|
||||
expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({
|
||||
id: ModelV2.ID.make("claude-sonnet"),
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves an explicit model variant", () => {
|
||||
expect(decode({ id: "claude-sonnet", providerID: "anthropic", variant: "high" })).toEqual({
|
||||
id: ModelV2.ID.make("claude-sonnet"),
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -24,8 +24,8 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AmazonBedrockPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const bedrock = provider("amazon-bedrock", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
|
||||
options: {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ describe("AnthropicPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AnthropicPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("anthropic", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" },
|
||||
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
|
|
@ -35,8 +35,8 @@ describe("AnthropicPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AnthropicPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => catalog.provider.update(provider("openai").id, () => {}))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {}))
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.headers["anthropic-beta"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzureCognitiveServicesPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
|
||||
item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
})
|
||||
|
|
@ -37,8 +37,8 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzureCognitiveServicesPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const azure = provider("azure-cognitive-services", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ import { Catalog } from "@opencode-ai/core/catalog"
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
|
||||
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
|
||||
|
||||
|
|
@ -16,7 +19,10 @@ const itWithAccount = testEffect(
|
|||
Layer.provideMerge(PluginV2.defaultLayer),
|
||||
Layer.provideMerge(AccountV2.defaultLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
|
||||
Layer.provide(Policy.defaultLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
|
||||
),
|
||||
Layer.provideMerge(npmLayer),
|
||||
),
|
||||
)
|
||||
|
|
@ -28,8 +34,8 @@ describe("AzurePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzurePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.azure, (item) => {
|
||||
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
|
||||
})
|
||||
|
|
@ -45,8 +51,8 @@ describe("AzurePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzurePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
|
||||
|
|
@ -94,8 +100,8 @@ describe("AzurePlugin", () => {
|
|||
),
|
||||
})
|
||||
yield* plugin.add(AzurePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.azure, (item) => {
|
||||
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
|
||||
})
|
||||
|
|
@ -113,8 +119,8 @@ describe("AzurePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzurePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
|
||||
|
|
@ -135,8 +141,8 @@ describe("AzurePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzurePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } },
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ describe("CerebrasPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(CerebrasPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
|
||||
item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" }
|
||||
item.options.headers.Existing = "1"
|
||||
|
|
@ -43,8 +43,8 @@ describe("CerebrasPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(CerebrasPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).options.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ import { Location } from "@opencode-ai/core/location"
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
|
||||
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
|
||||
|
||||
|
|
@ -17,7 +20,10 @@ const itWithAccount = testEffect(
|
|||
Layer.provideMerge(PluginV2.defaultLayer),
|
||||
Layer.provideMerge(AccountV2.defaultLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
|
||||
Layer.provide(Policy.defaultLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
|
||||
),
|
||||
Layer.provideMerge(npmLayer),
|
||||
),
|
||||
)
|
||||
|
|
@ -48,8 +54,8 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(CloudflareWorkersAIPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "test-provider" }
|
||||
}),
|
||||
|
|
@ -80,8 +86,8 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(CloudflareWorkersAIPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
|
||||
}),
|
||||
|
|
@ -146,8 +152,8 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
),
|
||||
})
|
||||
yield* plugin.add(CloudflareWorkersAIPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "test-provider" }
|
||||
}),
|
||||
|
|
@ -167,8 +173,8 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(CloudflareWorkersAIPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "test-provider" }
|
||||
provider.options.aisdk.provider.accountId = "configured-acct"
|
||||
|
|
|
|||
|
|
@ -152,8 +152,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GithubCopilotPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
|
|
@ -168,8 +168,8 @@ describe("GithubCopilotPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GithubCopilotPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { Catalog } from "@opencode-ai/core/catalog"
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
|
||||
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { it, model, npmLayer, withEnv } from "./provider-helper"
|
||||
|
||||
|
|
@ -27,12 +29,15 @@ void mock.module("gitlab-ai-provider", () => ({
|
|||
}))
|
||||
|
||||
const itWithAccount = testEffect(
|
||||
Catalog.layer.pipe(
|
||||
Layer.provideMerge(PluginV2.defaultLayer),
|
||||
Layer.provideMerge(AccountV2.defaultLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
|
||||
Layer.provideMerge(npmLayer),
|
||||
Layer.mergeAll(
|
||||
Catalog.defaultLayer,
|
||||
PluginV2.defaultLayer,
|
||||
AccountV2.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
npmLayer,
|
||||
).pipe(
|
||||
Layer.provide(Policy.defaultLayer),
|
||||
Layer.provide(Location.defaultLayer({ directory: AbsolutePath.make("/") })),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -179,8 +184,8 @@ describe("GitLabPlugin", () => {
|
|||
),
|
||||
})
|
||||
yield* plugin.add(GitLabPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
|
|
@ -227,8 +232,8 @@ describe("GitLabPlugin", () => {
|
|||
),
|
||||
})
|
||||
yield* plugin.add(GitLabPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexAnthropicPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
}),
|
||||
|
|
@ -41,8 +41,8 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexAnthropicPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
provider.options.aisdk.provider.project = "configured-project"
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ describe("GoogleVertexPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
|
|
@ -89,8 +89,8 @@ describe("GoogleVertexPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
|
|
@ -139,8 +139,8 @@ describe("GoogleVertexPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
|
|
@ -168,8 +168,8 @@ describe("GoogleVertexPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
|
|
@ -204,8 +204,8 @@ describe("GoogleVertexPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" }
|
||||
provider.options.aisdk.provider.project = "config-project"
|
||||
|
|
|
|||
|
|
@ -7,11 +7,17 @@ import { EventV2 } from "@opencode-ai/core/event"
|
|||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
|
||||
export const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
|
|
@ -25,7 +31,7 @@ export const npmLayer = Layer.succeed(
|
|||
export const catalogLayer = Layer.succeed(
|
||||
Catalog.Service,
|
||||
Catalog.Service.of({
|
||||
loader: () => Effect.die("unexpected catalog.loader"),
|
||||
transform: () => Effect.die("unexpected catalog.transform"),
|
||||
provider: {
|
||||
get: () => Effect.die("unexpected provider.get"),
|
||||
all: () => Effect.succeed([]),
|
||||
|
|
@ -36,7 +42,6 @@ export const catalogLayer = Layer.succeed(
|
|||
all: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed([]),
|
||||
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
|
||||
setDefault: () => Effect.die("unexpected model.setDefault"),
|
||||
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
|
||||
},
|
||||
}),
|
||||
|
|
@ -46,6 +51,7 @@ export const it = testEffect(
|
|||
Catalog.layer.pipe(
|
||||
Layer.provideMerge(PluginV2.defaultLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provide(Policy.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
Layer.provideMerge(npmLayer),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ describe("KiloPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(KiloPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const kilo = provider("kilo", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
|
|
@ -48,8 +48,8 @@ describe("KiloPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(KiloPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("kilo", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
})
|
||||
|
|
@ -74,8 +74,8 @@ describe("KiloPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(KiloPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const kilo = provider("kilo", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ describe("LLMGatewayPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(LLMGatewayPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const llmgateway = provider("llmgateway", {
|
||||
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
|
|
@ -56,8 +56,8 @@ describe("LLMGatewayPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(LLMGatewayPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("llmgateway", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ describe("NvidiaPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const nvidia = provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
|
|
@ -49,8 +49,8 @@ describe("NvidiaPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
|
|
@ -74,8 +74,8 @@ describe("NvidiaPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
options: {
|
||||
|
|
|
|||
|
|
@ -77,8 +77,8 @@ describe("OpenAIPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenAIPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
|
|
@ -96,8 +96,8 @@ describe("OpenAIPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenAIPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("custom-openai")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
|
|
|
|||
|
|
@ -5,11 +5,17 @@ import { Location } from "@opencode-ai/core/location"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { it, model, provider, withEnv } from "./provider-helper"
|
||||
|
||||
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
|
||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
|
||||
describe("OpencodePlugin", () => {
|
||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||
|
|
@ -18,8 +24,8 @@ describe("OpencodePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
|
|
@ -39,8 +45,8 @@ describe("OpencodePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const free = model("opencode", "free", { cost: cost(0) })
|
||||
|
|
@ -60,8 +66,8 @@ describe("OpencodePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
|
||||
|
|
@ -81,8 +87,8 @@ describe("OpencodePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
|
|
@ -102,8 +108,8 @@ describe("OpencodePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.env = [...item.env]
|
||||
|
|
@ -125,8 +131,8 @@ describe("OpencodePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", {
|
||||
options: {
|
||||
headers: {},
|
||||
|
|
@ -157,8 +163,8 @@ describe("OpencodePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", { enabled: { via: "account", service: "opencode" } })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.enabled = item.enabled
|
||||
|
|
@ -180,8 +186,8 @@ describe("OpencodePlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("openai")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("openai", "paid", { cost: cost(1) })
|
||||
|
|
@ -200,8 +206,8 @@ describe("OpencodePlugin", () => {
|
|||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.opencode
|
||||
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
|
|
@ -220,6 +226,8 @@ describe("OpencodePlugin", () => {
|
|||
const selected = yield* catalog.model.small(providerID)
|
||||
|
||||
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
|
||||
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))),
|
||||
}).pipe(
|
||||
Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(Policy.defaultLayer), Layer.provide(locationLayer))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ describe("OpenRouterPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenRouterPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const openrouter = provider("openrouter", {
|
||||
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
|
|
@ -75,8 +75,8 @@ describe("OpenRouterPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenRouterPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const openrouter = provider("openrouter", {
|
||||
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
||||
})
|
||||
|
|
@ -108,8 +108,8 @@ describe("OpenRouterPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenRouterPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ describe("VercelPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(VercelPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("vercel", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/vercel" },
|
||||
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
|
|
@ -36,8 +36,8 @@ describe("VercelPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(VercelPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
|
|
@ -69,8 +69,8 @@ describe("VercelPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(VercelPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).options.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ describe("ZenmuxPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(ZenmuxPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
})
|
||||
|
|
@ -42,8 +42,8 @@ describe("ZenmuxPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(ZenmuxPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
|
|
@ -67,8 +67,8 @@ describe("ZenmuxPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(ZenmuxPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
options: {
|
||||
|
|
@ -95,8 +95,8 @@ describe("ZenmuxPlugin", () => {
|
|||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(ZenmuxPlugin)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("openrouter", {
|
||||
options: {
|
||||
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
|
||||
|
|
|
|||
83
packages/core/test/policy.test.ts
Normal file
83
packages/core/test/policy.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Policy.defaultLayer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("Policy", () => {
|
||||
it.effect("returns the caller's fallback when no statement matches", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
|
||||
expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("evaluates wildcard provider rules in written order", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
yield* policy.load([
|
||||
new Policy.Info({
|
||||
effect: "deny",
|
||||
action: "provider.*",
|
||||
resource: "*",
|
||||
}),
|
||||
new Policy.Info({
|
||||
effect: "allow",
|
||||
action: "provider.use",
|
||||
resource: "anthropic",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
|
||||
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("matches action and resource independently", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
yield* policy.load([
|
||||
new Policy.Info({
|
||||
effect: "deny",
|
||||
action: "provider.*",
|
||||
resource: "company-*",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny")
|
||||
expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the last matching loaded statement", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
yield* policy.load([
|
||||
new Policy.Info({
|
||||
effect: "allow",
|
||||
action: "provider.use",
|
||||
resource: "openai",
|
||||
}),
|
||||
new Policy.Info({
|
||||
effect: "deny",
|
||||
action: "provider.use",
|
||||
resource: "openai",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -49,7 +49,7 @@ describe("ProjectV2.resolve", () => {
|
|||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make("global"))
|
||||
expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
|
||||
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs).toBeUndefined()
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Effect, Option } from "effect"
|
|||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
export const V2Command = effectCmd({
|
||||
|
|
@ -37,7 +38,7 @@ export const V2Command = effectCmd({
|
|||
Effect.withSpan("Cli.debug.v2"),
|
||||
Effect.provide(
|
||||
LocationServiceMap.get({
|
||||
directory: process.cwd(),
|
||||
directory: AbsolutePath.make(process.cwd()),
|
||||
}),
|
||||
),
|
||||
Effect.provide(LocationServiceMap.layer),
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { ConfigSkills } from "./skills"
|
|||
import { ConfigVariable } from "./variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { ConfigExperimental } from "@opencode-ai/core/config/experimental"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
|
|
@ -301,6 +302,9 @@ export const Info = Schema.Struct({
|
|||
mcp_timeout: Schema.optional(PositiveInt).annotate({
|
||||
description: "Timeout in milliseconds for model context protocol (MCP) requests",
|
||||
}),
|
||||
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
|
||||
description: "Policy statements applied to supported resources, such as provider access",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}).annotate({ identifier: "Config" })
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpServerRequest } from "effect/unstable/http"
|
||||
|
|
@ -40,7 +41,9 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service<
|
|||
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
||||
const query = new URL(request.url, "http://localhost").searchParams
|
||||
return {
|
||||
directory: query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
|
||||
directory: AbsolutePath.make(
|
||||
query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
|
||||
),
|
||||
workspaceID: query.get("location[workspace]") || request.headers["x-opencode-workspace"],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export const layer = Layer.effect(
|
|||
? {
|
||||
id: ModelV2.ID.make(row.model.id),
|
||||
providerID: ProviderV2.ID.make(row.model.providerID),
|
||||
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
|
||||
variant: row.model.variant ? ModelV2.VariantID.make(row.model.variant) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
cost: row.cost,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import { createServer, type Server } from "node:http"
|
|||
import { streamText } from "ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { disposeAllInstances, provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { Env } from "@/env"
|
||||
import { Plugin } from "@/plugin"
|
||||
|
|
@ -22,70 +21,66 @@ const it = testEffect(
|
|||
Provider.defaultLayer,
|
||||
Env.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
TestLLMServer.layer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
|
||||
provideTmpdirServer(
|
||||
({ llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop())
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => delayedBodyServer(250)),
|
||||
(server) => Effect.sync(() => server.server.close()),
|
||||
)
|
||||
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
expect(yield* Effect.promise(() => result.text)).toBe("late")
|
||||
}),
|
||||
{
|
||||
config: (url) => {
|
||||
const config = testProviderConfig(url)
|
||||
return {
|
||||
...config,
|
||||
provider: {
|
||||
test: {
|
||||
...config.provider.test,
|
||||
options: { ...config.provider.test.options, headerTimeout: 50 },
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
expect(yield* Effect.promise(() => result.text)).toBe("late")
|
||||
}),
|
||||
{ config: providerConfig(server.url, { headerTimeout: 50 }) },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("chunkTimeout raises a response stream error when SSE body stalls", () =>
|
||||
provideTmpdirServer(
|
||||
({ llm }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop())
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => delayedBodyServer(250)),
|
||||
(server) => Effect.sync(() => server.server.close()),
|
||||
)
|
||||
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
onError() {},
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
onError() {},
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
const error = yield* Effect.promise(async () => {
|
||||
try {
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") return part.error
|
||||
const error = yield* Effect.promise(async () => {
|
||||
try {
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") return part.error
|
||||
}
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
})
|
||||
expect(error).toBeInstanceOf(ProviderError.ResponseStreamError)
|
||||
}),
|
||||
{ config: (url) => providerConfig(url, { chunkTimeout: 50 }) },
|
||||
),
|
||||
})
|
||||
expect(error).toBeInstanceOf(ProviderError.ResponseStreamError)
|
||||
}),
|
||||
{ config: providerConfig(server.url, { chunkTimeout: 50 }) },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("headerTimeout aborts when response headers do not arrive", () =>
|
||||
|
|
@ -205,6 +200,20 @@ async function delayedHeaderServer(delay: number): Promise<{ server: Server; url
|
|||
return { server, url: `http://127.0.0.1:${address.port}` }
|
||||
}
|
||||
|
||||
async function delayedBodyServer(delay: number): Promise<{ server: Server; url: string }> {
|
||||
const server = createServer((_, res) => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" })
|
||||
res.flushHeaders()
|
||||
setTimeout(() => {
|
||||
res.end('data: {"choices":[{"delta":{"content":"late"}}]}\n\ndata: [DONE]\n\n')
|
||||
}, delay)
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port")
|
||||
return { server, url: `http://127.0.0.1:${address.port}` }
|
||||
}
|
||||
|
||||
function withAuthContent<A, E, R>(self: Effect.Effect<A, E, R>, value: Record<string, unknown> = defaultAuthContent()) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { tool, type ModelMessage, type JSONValue } from "ai"
|
||||
|
|
@ -276,6 +277,7 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) {
|
|||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(ModelsDev.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
)
|
||||
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
|
||||
const recordedClient = LLMClient.layer.pipe(
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ export default defineConfig({
|
|||
"commands",
|
||||
"formatters",
|
||||
"permissions",
|
||||
"policies",
|
||||
"lsp",
|
||||
"mcp-servers",
|
||||
"acp",
|
||||
|
|
|
|||
|
|
@ -393,6 +393,29 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo
|
|||
|
||||
---
|
||||
|
||||
### Policies
|
||||
|
||||
Use the `experimental.policies` option to allow or deny OpenCode actions on configured resources. Currently, policies can control which providers OpenCode may use.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{
|
||||
"effect": "deny",
|
||||
"action": "provider.use",
|
||||
"resource": "openai"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[Learn more about policies here](/docs/policies).
|
||||
|
||||
---
|
||||
|
||||
### Image attachments
|
||||
|
||||
OpenCode normalizes image attachments before sending them to the model. By default, images are resized when they exceed `2000x2000` pixels or `5242880` base64 bytes.
|
||||
|
|
|
|||
137
packages/web/src/content/docs/policies.mdx
Normal file
137
packages/web/src/content/docs/policies.mdx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
---
|
||||
title: Policies
|
||||
description: Control which configured resources OpenCode may use.
|
||||
---
|
||||
|
||||
Policies control whether OpenCode may perform an action on a named resource. This feature is experimental and is configured with the `experimental.policies` array in `opencode.json`.
|
||||
|
||||
Policies are separate from [permissions](/docs/permissions). Permissions control what tools can do during a session, while policies control whether OpenCode may use a resource such as an LLM provider.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Each policy statement has three fields:
|
||||
|
||||
- `effect` - Either `"allow"` or `"deny"`.
|
||||
- `action` - The operation being controlled.
|
||||
- `resource` - The resource ID or wildcard pattern the statement applies to.
|
||||
|
||||
For example, deny use of the `openai` provider:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{
|
||||
"effect": "deny",
|
||||
"action": "provider.use",
|
||||
"resource": "openai"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A provider denied by policy is not available for model selection or model use, even if it has credentials or is otherwise configured correctly.
|
||||
|
||||
---
|
||||
|
||||
## Available Policies
|
||||
|
||||
OpenCode currently supports one policy action:
|
||||
|
||||
| Action | Resource | Description |
|
||||
| -------------- | ------------------------------ | ------------------------------------------ |
|
||||
| `provider.use` | Provider ID, such as `openai` | Allow or deny use of an LLM provider. |
|
||||
|
||||
More policy actions may be added in the future.
|
||||
|
||||
---
|
||||
|
||||
## Matching
|
||||
|
||||
The `resource` field supports wildcard matching. Use `*` to match zero or more characters and `?` to match one character.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{
|
||||
"effect": "deny",
|
||||
"action": "provider.use",
|
||||
"resource": "company-*"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This denies providers such as `company-us` and `company-eu`.
|
||||
|
||||
---
|
||||
|
||||
## Rule Order
|
||||
|
||||
When multiple statements match, the last matching statement wins. Put broad rules first, then more specific exceptions after them.
|
||||
|
||||
For example, allow only Anthropic:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{
|
||||
"effect": "deny",
|
||||
"action": "provider.use",
|
||||
"resource": "*"
|
||||
},
|
||||
{
|
||||
"effect": "allow",
|
||||
"action": "provider.use",
|
||||
"resource": "anthropic"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If no policy matches a provider, provider use is allowed by default.
|
||||
|
||||
Policies may be set in both your global config and project config. If policies from both locations match the same provider, your global policy takes priority over the project policy. This prevents a repository from re-enabling a provider that you deny globally.
|
||||
|
||||
---
|
||||
|
||||
## Provider Lists
|
||||
|
||||
Use policies instead of the older `disabled_providers` and `enabled_providers` settings when controlling provider access.
|
||||
|
||||
To replace `disabled_providers`:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{ "effect": "deny", "action": "provider.use", "resource": "openai" },
|
||||
{ "effect": "deny", "action": "provider.use", "resource": "google" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To replace `enabled_providers`, deny all providers first and allow the selected providers after it:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"experimental": {
|
||||
"policies": [
|
||||
{ "effect": "deny", "action": "provider.use", "resource": "*" },
|
||||
{ "effect": "allow", "action": "provider.use", "resource": "anthropic" },
|
||||
{ "effect": "allow", "action": "provider.use", "resource": "openai" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue