From 9e556b0f6ca3fecd0fef6198acddc11680a4b243 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 27 May 2026 16:27:46 -0400 Subject: [PATCH 01/19] feat(core): add location-scoped config loading --- bun.lock | 1 + packages/core/package.json | 1 + packages/core/src/agent.ts | 147 ------------- packages/core/src/config/config.ts | 89 ++++++++ packages/core/src/config/provider.ts | 121 ++++++++++ packages/core/src/config/schema.ts | 28 +++ packages/core/src/event.ts | 2 +- packages/core/src/location-layer.ts | 10 +- packages/core/src/location.ts | 35 ++- packages/core/src/plugin.ts | 22 -- packages/core/src/plugin/boot.ts | 12 +- packages/core/src/project.ts | 3 +- packages/core/test/catalog.test.ts | 7 +- packages/core/test/config/config.test.ts | 208 ++++++++++++++++++ packages/core/test/config/provider.test.ts | 138 ++++++++++++ packages/core/test/event.test.ts | 6 +- packages/core/test/fixture/location.ts | 12 + packages/core/test/location.test.ts | 38 ++++ .../core/test/plugin/provider-azure.test.ts | 6 +- .../provider-cloudflare-workers-ai.test.ts | 6 +- .../core/test/plugin/provider-gitlab.test.ts | 6 +- packages/core/test/plugin/provider-helper.ts | 7 +- .../test/plugin/provider-opencode.test.ts | 7 +- packages/core/test/project.test.ts | 2 +- packages/opencode/src/cli/cmd/debug/v2.ts | 3 +- .../instance/httpapi/groups/v2/location.ts | 5 +- 26 files changed, 727 insertions(+), 195 deletions(-) delete mode 100644 packages/core/src/agent.ts create mode 100644 packages/core/src/config/config.ts create mode 100644 packages/core/src/config/provider.ts create mode 100644 packages/core/src/config/schema.ts create mode 100644 packages/core/test/config/config.test.ts create mode 100644 packages/core/test/config/provider.test.ts create mode 100644 packages/core/test/fixture/location.ts create mode 100644 packages/core/test/location.test.ts diff --git a/bun.lock b/bun.lock index a5764fc15f..9cdf59bf28 100644 --- a/bun.lock +++ b/bun.lock @@ -256,6 +256,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", diff --git a/packages/core/package.json b/packages/core/package.json index 3b92ecd2f4..9c8a55e99a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts deleted file mode 100644 index 7f4456c59f..0000000000 --- a/packages/core/src/agent.ts +++ /dev/null @@ -1,147 +0,0 @@ -export * as AgentV2 from "./agent" - -import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect" -import { produce, type Draft } from "immer" -import { ModelV2 } from "./model" -import { PermissionV2 } from "./permission" -import { PluginV2 } from "./plugin" -import { ProviderV2 } from "./provider" - -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 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, - model: ModelV2.Ref.pipe(Schema.optional), - 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 - -export class NotFoundError extends Schema.TaggedErrorClass()("AgentV2.NotFound", { - agent: ID, -}) {} - -export class InvalidDefaultError extends Schema.TaggedErrorClass()("AgentV2.InvalidDefault", { - agent: ID, - reason: Schema.Literals(["missing", "subagent", "hidden"]), -}) {} - -export class NoDefaultError extends Schema.TaggedErrorClass()("AgentV2.NoDefault", {}) {} - -export interface Interface { - readonly get: (agent: ID) => Effect.Effect - readonly list: () => Effect.Effect - readonly update: (agent: ID, fn: (agent: Draft) => void) => Effect.Effect - readonly remove: (agent: ID) => Effect.Effect - readonly defaultInfo: () => Effect.Effect - readonly defaultAgent: () => Effect.Effect - readonly setDefault: (agent: ID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/Agent") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - let agents = HashMap.empty() - 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 - }), - - list: Effect.fn("AgentV2.list")(function* () { - return pipe( - HashMap.toValues(agents), - Array.sortWith((agent) => agent.name, Order.String), - ) - }), - - 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 }) - }), - - 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)) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts new file mode 100644 index 0000000000..14686f4157 --- /dev/null +++ b/packages/core/src/config/config.ts @@ -0,0 +1,89 @@ +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 { AbsolutePath } from "../schema" +import { ConfigV2 } from "./schema" + +export interface Interface { + /** Returns supplemental config directories from lowest to highest priority. */ + readonly directories: () => Effect.Effect + /** Loads location config files from lowest to highest priority. */ + readonly get: () => Effect.Effect +} + +export class Service extends Context.Service()("@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 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 + + const info = Option.getOrUndefined(Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all" })) + if (!info) return + return new ConfigV2.Loaded({ source: new ConfigV2.FileSource({ 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 ConfigV2.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 ConfigV2.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()] + + 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)) diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts new file mode 100644 index 0000000000..47d839302c --- /dev/null +++ b/packages/core/src/config/provider.ts @@ -0,0 +1,121 @@ +export * as ConfigProvider from "./provider" + +import { Effect, Schema } from "effect" +import { Catalog } from "../catalog" +import { Config } from "./config" +import { ProviderV2 } from "../provider" +import { ModelV2 } from "../model" +import { PluginV2 } from "../plugin" + +class Model extends Schema.Class("ConfigV2.Model")({ + apiID: 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({ + ...ProviderV2.Options.fields, + variant: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional), + variants: Schema.Struct({ + id: ModelV2.VariantID, + ...ProviderV2.Options.fields, + }).pipe(Schema.Array, Schema.optional), + cost: ModelV2.Cost.pipe(Schema.Array).pipe(Schema.optional), + enabled: Schema.Boolean.pipe(Schema.optional), + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, + }).pipe(Schema.optional), +}) {} + +export class Info extends Schema.Class("ConfigV2.Provider")({ + name: Schema.String.pipe(Schema.optional), + endpoint: ProviderV2.Endpoint.pipe(Schema.optional), + options: ProviderV2.Options.pipe(Schema.optional), + models: Schema.Record(Schema.String, Model).pipe(Schema.optional), +}) {} + +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 load = yield* catalog.loader() + const files = yield* config.get() + + yield* load((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 + 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.apiID !== undefined) model.apiID = config.apiID + 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 = config.cost.map((cost) => ({ + tier: cost.tier && { ...cost.tier }, + input: cost.input, + output: cost.output, + cache: { ...cost.cache }, + })) + } + if (config.enabled !== undefined) model.enabled = config.enabled + if (config.limit !== undefined) model.limit = { ...config.limit } + }) + } + } + } + }) + }), +}) diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts new file mode 100644 index 0000000000..da4415c8eb --- /dev/null +++ b/packages/core/src/config/schema.ts @@ -0,0 +1,28 @@ +export * as ConfigV2 from "./schema" + +import { Schema } from "effect" +import { ConfigProvider } from "./provider" + +export class Info extends Schema.Class("ConfigV2.Info")({ + $schema: Schema.optional(Schema.String).annotate({ + description: "JSON schema reference for configuration validation", + }), + providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), +}) {} + +export class FileSource extends Schema.Class("ConfigV2.FileSource")({ + type: Schema.Literal("file"), + path: Schema.String, +}) {} + +export class MemorySource extends Schema.Class("ConfigV2.MemorySource")({ + type: Schema.Literal("memory"), +}) {} + +export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type")) +export type Source = typeof Source.Type + +export class Loaded extends Schema.Class("ConfigV2.Loaded")({ + source: Source, + info: Info, +}) {} diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index a4a5dd8595..339fbddecf 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -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 return yield* publishEvent(event) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index c40a940430..f7d87de802 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -4,10 +4,12 @@ import { Catalog } from "./catalog" import { PluginBoot } from "./plugin/boot" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { - lookup: (ref: Location.Ref) => - Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe( - Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]), - ), + lookup: (ref: Location.Ref) => { + const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe( + Layer.provideMerge(Location.defaultLayer(ref)), + ) + return result + }, idleTimeToLive: "5 minutes", dependencies: [], }) {} diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 00ff9cd3ea..68c9a8f791 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -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()("@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()("@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)) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index ab2d4cbf7d..e78dbc4059 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -4,7 +4,6 @@ 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 type { ModelV2 } from "./model" -import type { AgentV2 } from "./agent" import type { Catalog } from "./catalog" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) @@ -43,27 +42,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 = { diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 5624369e04..e6c3ac74a1 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -2,8 +2,9 @@ export * as PluginBoot from "./boot" import { Context, Deferred, Effect, Layer } from "effect" import { AccountV2 } from "../account" -import { AgentV2 } from "../agent" import { Catalog } from "../catalog" +import { Config } from "../config/config" +import { ConfigProvider } from "../config/provider" import { EventV2 } from "../event" import { Npm } from "../npm" import { PluginV2 } from "../plugin" @@ -15,7 +16,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 | Npm.Service | EventV2.Service | PluginV2.Service | Config.Service > } @@ -28,10 +29,10 @@ export class Service extends Context.Service()("@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 config = yield* Config.Service const npm = yield* Npm.Service const events = yield* EventV2.Service const done = yield* Deferred.make() @@ -41,8 +42,8 @@ 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(Config.Service, config), Effect.provideService(Npm.Service, npm), Effect.provideService(EventV2.Service, events), Effect.provideService(PluginV2.Service, plugin), @@ -57,6 +58,7 @@ export const layer = Layer.effect( yield* add(item) } yield* add(ModelsDevPlugin) + yield* add(ConfigProvider.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( @@ -72,10 +74,10 @@ 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(Config.defaultLayer), Layer.provide(Npm.defaultLayer), ) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 9c265d75be..90638873d7 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -25,7 +25,6 @@ export type Vcs = typeof Vcs.Type export class Info extends Schema.Class("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)) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 97f816d005..59997a88a6 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -6,9 +6,14 @@ import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" 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), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts new file mode 100644 index 0000000000..c89a7ec145 --- /dev/null +++ b/packages/core/test/config/config.test.ts @@ -0,0 +1,208 @@ +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/config" +import { ConfigProvider } from "@opencode-ai/core/config/provider" +import { ConfigV2 } from "@opencode-ai/core/config/schema" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +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.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(ConfigV2.Loaded) + expect(documents[0]?.source).toBeInstanceOf(ConfigV2.FileSource) + 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("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 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")), + }), + ), + ) + }) + }), + ), + ) +}) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts new file mode 100644 index 0000000000..6f9b3181f2 --- /dev/null +++ b/packages/core/test/config/provider.test.ts @@ -0,0 +1,138 @@ +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/config" +import { ConfigProvider } from "@opencode-ai/core/config/provider" +import { ConfigV2 } from "@opencode-ai/core/config/schema" +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, variant?: string) { + return { + headers, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + variant, + } +} + +const decode = Schema.decodeUnknownSync(ConfigV2.Info) + +describe("ConfigProvider.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 ConfigV2.Loaded({ + source: new ConfigV2.MemorySource({ type: "memory" }), + info: decode({ + providers: { + custom: { + name: "Configured", + endpoint: { type: "unknown" }, + options: options({ first: "first", shared: "first" }), + models: { + chat: { + name: "First", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + enabled: false, + limit: { context: 100, output: 50 }, + options: options({ first: "first", shared: "first" }, "retained"), + variants: [ + { + id: "fast", + headers: { first: "first", shared: "first" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + ], + }, + }, + }, + }, + }), + }), + new ConfigV2.Loaded({ + source: new ConfigV2.MemorySource({ type: "memory" }), + info: decode({ + providers: { + custom: { + endpoint: { type: "aisdk", package: "custom-sdk", url: "https://example.test" }, + options: options({ last: "last", shared: "last" }), + models: { + chat: { + apiID: "api-chat", + name: "Last", + options: options({ last: "last", shared: "last" }), + variants: [ + { + id: "fast", + headers: { last: "last", shared: "last" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + { + id: "slow", + headers: { slow: "slow" }, + body: {}, + aisdk: { provider: {}, request: {} }, + }, + ], + }, + }, + }, + }, + }), + }), + new ConfigV2.Loaded({ + source: new ConfigV2.MemorySource({ type: "memory" }), + info: decode({ + providers: { + custom: { name: "Renamed" }, + }, + }), + }), + ]), + }) + + yield* plugin.add({ + ...ConfigProvider.Plugin, + effect: ConfigProvider.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.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: 50 }) + 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" }) + }), + ) +}) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index b67b2897a1..f5cce54de4 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -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" }) }), ) diff --git a/packages/core/test/fixture/location.ts b/packages/core/test/fixture/location.ts new file mode 100644 index 0000000000..00b3ffbd13 --- /dev/null +++ b/packages/core/test/fixture/location.ts @@ -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 +} diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts new file mode 100644 index 0000000000..305083bfed --- /dev/null +++ b/packages/core/test/location.test.ts @@ -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"), + }) + }), + ) +}) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 8c8995a372..07422dfd48 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -8,6 +8,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin" 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 +18,9 @@ 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.provideMerge( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), Layer.provideMerge(npmLayer), ), ) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index d1db7b27a1..40e501bd35 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -9,6 +9,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin" 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 +19,9 @@ 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.provideMerge( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), Layer.provideMerge(npmLayer), ), ) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index e785fbbb7f..7158535cc6 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -8,6 +8,8 @@ import { PluginV2 } from "@opencode-ai/core/plugin" 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 { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { it, model, npmLayer, withEnv } from "./provider-helper" @@ -31,7 +33,9 @@ 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.provideMerge( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ), Layer.provideMerge(npmLayer), ), ) diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index 1b8f1c65a0..5257140c50 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -8,10 +8,15 @@ import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" 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, diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 3f59a34977..62159a92ca 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -6,10 +6,15 @@ import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" 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", () => diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index c5b96b6389..b1ae5b45c3 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -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() }), diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 56866a0e02..aab7018982 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -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), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts index f2a9a33557..c9b21b5adf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts @@ -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"], } } From e24b589da1dcfb1cde79f22e2c11150a4193b502 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 27 May 2026 22:07:26 -0400 Subject: [PATCH 02/19] feat(core): add provider policy enforcement --- packages/core/src/catalog.ts | 25 +- packages/core/src/config/config.ts | 16 +- packages/core/src/config/schema.ts | 15 + packages/core/src/location-layer.ts | 7 +- packages/core/src/policy.ts | 44 +++ packages/core/test/catalog.test.ts | 21 ++ packages/core/test/config/config.test.ts | 96 ++++++ .../core/test/plugin/provider-azure.test.ts | 2 + .../provider-cloudflare-workers-ai.test.ts | 2 + .../core/test/plugin/provider-gitlab.test.ts | 19 +- packages/core/test/plugin/provider-helper.ts | 2 + .../test/plugin/provider-opencode.test.ts | 5 +- packages/core/test/policy.test.ts | 83 ++++++ packages/opencode/src/config/config.ts | 4 + packages/opencode/src/provider/provider.ts | 15 +- .../opencode/test/provider/provider.test.ts | 17 ++ .../test/session/llm-native-recorded.test.ts | 2 + packages/web/astro.config.mjs | 1 + packages/web/src/content/docs/config.mdx | 21 ++ packages/web/src/content/docs/policies.mdx | 127 ++++++++ specs/v2/config.md | 169 +++++++++++ specs/v2/provider-policy.md | 275 ++++++++++++++++++ 22 files changed, 946 insertions(+), 22 deletions(-) create mode 100644 packages/core/src/policy.ts create mode 100644 packages/core/test/policy.test.ts create mode 100644 packages/web/src/content/docs/policies.mdx create mode 100644 specs/v2/config.md create mode 100644 specs/v2/provider-policy.md diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 2bbd7bc49b..b12db3ff9e 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -7,6 +7,7 @@ import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" import { Location } from "./location" import { EventV2 } from "./event" +import { Policy } from "./policy" export type ProviderRecord = { provider: ProviderV2.Info @@ -25,6 +26,8 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass { @@ -199,16 +203,23 @@ export const layer = Layer.effect( 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 + const applyPolicy = Effect.fn("CatalogV2.applyPolicy")(function* (draft: { + records: HashMap.HashMap + data: ProviderRecord[] + }) { + const ctx = context(draft) + for (const record of [...draft.data]) { + if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { + ctx.provider.remove(record.provider.id) + } + } }) const rebuild = Effect.fn("CatalogV2.rebuild")(function* () { const draft = { records: HashMap.empty(), data: [] as ProviderRecord[] } for (const loader of loaders) loader.update(context(draft)) yield* plugin.trigger("catalog.transform", context(draft), {}) + yield* applyPolicy(draft) records = draft.records }) @@ -217,6 +228,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const draft = { records: clone(records), data: HashMap.toValues(records) } yield* plugin.triggerFor(id, "catalog.transform", context(draft), {}) + yield* applyPolicy(draft) records = draft.records }), ), @@ -354,4 +366,7 @@ export const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ -export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(EventV2.defaultLayer), + Layer.provide(PluginV2.defaultLayer), +) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 14686f4157..273c24679d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -6,6 +6,7 @@ import { Context, Effect, Layer, Option, Schema } from "effect" import { AppFileSystem } from "../filesystem" import { Global } from "../global" import { Location } from "../location" +import { Policy } from "../policy" import { AbsolutePath } from "../schema" import { ConfigV2 } from "./schema" @@ -24,6 +25,7 @@ export const layer = Layer.effect( 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) { @@ -34,7 +36,11 @@ export const layer = Layer.effect( const input: unknown = parse(text, errors, { allowTrailingComma: true }) if (errors.length) return - const info = Option.getOrUndefined(Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all" })) + // Accept legacy fields while v2 is migrated incrementally; recognized + // fields still have to satisfy the v2 schema. + const info = Option.getOrUndefined( + Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all", onExcessProperty: "ignore" }), + ) if (!info) return return new ConfigV2.Loaded({ source: new ConfigV2.FileSource({ type: "file", path: filepath }), info }) }) @@ -74,6 +80,9 @@ export const layer = Layer.effect( // 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.policies ?? [])) return Service.of({ directories: Effect.fn("Config.directories")(function* () { @@ -86,4 +95,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.defaultLayer), +) diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index da4415c8eb..b308bcabfe 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -1,12 +1,27 @@ export * as ConfigV2 from "./schema" import { Schema } from "effect" +import { Catalog } from "../catalog" +import { Policy as PolicyV2 } from "../policy" import { ConfigProvider } from "./provider" +// 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("ConfigV2.Policy")({ + ...PolicyV2.Info.fields, + action: PolicyAction, +}) {} + export class Info extends Schema.Class("ConfigV2.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", + }), + policies: Policy.pipe(Schema.Array, Schema.optional), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), }) {} diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index f7d87de802..905206fd63 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -2,14 +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/config" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { - const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe( + const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer).pipe( + Layer.provideMerge(Policy.defaultLayer), Layer.provideMerge(Location.defaultLayer(ref)), ) return result }, - idleTimeToLive: "5 minutes", + idleTimeToLive: "60 minutes", dependencies: [], }) {} diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts new file mode 100644 index 0000000000..78bd74f1cd --- /dev/null +++ b/packages/core/src/policy.ts @@ -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("Policy.Info")({ + action: Schema.String, + effect: Effect, + resource: Schema.String, +}) {} + +export interface Interface { + readonly load: (statements: Info[]) => EffectRuntime.Effect + readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect +} + +export class Service extends Context.Service()("@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 diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 59997a88a6..fe651e0d78 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -5,6 +5,7 @@ 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" @@ -18,6 +19,7 @@ const it = testEffect( Catalog.layer.pipe( Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(PluginV2.defaultLayer), + Layer.provideMerge(Policy.defaultLayer), Layer.provideMerge(locationLayer), ), ) @@ -242,4 +244,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 load = yield* catalog.loader() + + yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) + yield* load((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()) + }), + ) }) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index c89a7ec145..9c9063aa53 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -8,6 +8,7 @@ import { ConfigV2 } from "@opencode-ai/core/config/schema" 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" @@ -25,6 +26,7 @@ function testLayer( return Config.layer.pipe( Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layerWith({ config: globalDirectory })), + Layer.provideMerge(Policy.defaultLayer), Layer.provide( Layer.succeed( Location.Service, @@ -120,6 +122,70 @@ describe("Config", () => { ), ) + 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", + 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.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 recognized v2 fields from config files that still contain legacy fields", () => + 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", + disabled_providers: ["openai"], + server: { port: 4096 }, + }), + ), + ) + + 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") + }).pipe(Effect.provide(testLayer(tmp.path))) + }), + ), + ), + ) + it.live("ignores invalid files while loading valid config values", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -145,6 +211,36 @@ describe("Config", () => { ), ) + 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({ policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }), + ) + await fs.writeFile( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ 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()), diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 07422dfd48..ae4a03140d 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -5,6 +5,7 @@ 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" @@ -18,6 +19,7 @@ const itWithAccount = testEffect( Layer.provideMerge(PluginV2.defaultLayer), Layer.provideMerge(AccountV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), + Layer.provide(Policy.defaultLayer), Layer.provideMerge( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), ), diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 40e501bd35..2495a0646d 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -6,6 +6,7 @@ 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" @@ -19,6 +20,7 @@ const itWithAccount = testEffect( Layer.provideMerge(PluginV2.defaultLayer), Layer.provideMerge(AccountV2.defaultLayer), Layer.provideMerge(EventV2.defaultLayer), + Layer.provide(Policy.defaultLayer), Layer.provideMerge( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), ), diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index 7158535cc6..e804d15cfc 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -5,11 +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 { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { it, model, npmLayer, withEnv } from "./provider-helper" @@ -29,14 +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(location({ directory: AbsolutePath.make("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("/") })), ), ) diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index 5257140c50..2e4c9ab622 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -7,6 +7,7 @@ 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" @@ -51,6 +52,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), ), diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 62159a92ca..9ed2894fa6 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -5,6 +5,7 @@ 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" @@ -225,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))), + ), ) }) diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts new file mode 100644 index 0000000000..c331b5585f --- /dev/null +++ b/packages/core/test/policy.test.ts @@ -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") + }), + ) +}) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 456d6c3ee3..dd8d872c9d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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 { ConfigV2 } from "@opencode-ai/core/config/schema" const log = Log.create({ service: "config" }) @@ -177,6 +178,9 @@ export const Info = Schema.Struct({ enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "When set, ONLY these providers will be enabled. All other providers will be ignored", }), + policies: Schema.optional(Schema.mutable(Schema.Array(ConfigV2.Policy))).annotate({ + description: "Policy statements applied to supported resources, such as provider access", + }), model: Schema.optional(ConfigModelID).annotate({ description: "Model to use in the format of provider/model, eg anthropic/claude-2", }), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6f3cfccc51..adb45005b9 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -29,6 +29,9 @@ import { ModelID, ProviderID } from "./schema" import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderError } from "./error" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { Policy } from "@opencode-ai/core/policy" +import { AbsolutePath } from "@opencode-ai/core/schema" const log = Log.create({ service: "provider" }) const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 @@ -1207,9 +1210,11 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const modelsDevSvc = yield* ModelsDev.Service const runtimeFlags = yield* RuntimeFlags.Service + const locations = yield* LocationServiceMap - const state = yield* InstanceState.make(() => + const state = yield* InstanceState.make((ctx) => Effect.gen(function* () { + const policy = yield* Policy.Service using _ = log.time("state") const bridge = yield* EffectBridge.make() const cfg = yield* config.get() @@ -1480,7 +1485,10 @@ export const layer = Layer.effect( for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderID.make(id) - if (!isProviderAllowed(providerID)) { + if ( + !isProviderAllowed(providerID) || + (yield* policy.evaluate("provider.use", providerID, "allow")) === "deny" + ) { delete providers[providerID] continue } @@ -1537,7 +1545,7 @@ export const layer = Layer.effect( modelLoaders, varsLoaders, } - }), + }).pipe(Effect.provide(locations.get({ directory: AbsolutePath.make(ctx.directory) }))), ) const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) @@ -1873,6 +1881,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(LocationServiceMap.layer), ), ) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 1ea2c82424..62c25bae9a 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -6,6 +6,7 @@ import { ModelsDev } from "@opencode-ai/core/models-dev" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Global } from "@opencode-ai/core/global" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { disposeAllInstances, provideInstanceEffect, tmpdirScoped, TestInstance } from "../fixture/fixture" import { markPluginDependenciesReady } from "../fixture/plugin" import { Auth } from "@/auth" @@ -63,6 +64,7 @@ const providerLayer = (flags: Partial = {}) => Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.layer(flags)), + Layer.provide(LocationServiceMap.layer), ) const list = Provider.use.list() @@ -100,6 +102,11 @@ const alphaProviderConfig = { }, } +const denyAnthropicPolicyConfig = { + provider: {}, + policies: [{ effect: "deny" as const, action: "provider.use" as const, resource: "anthropic" }], +} + it.instance("provider loaded from env variable", () => Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") @@ -131,6 +138,16 @@ it.instance( { config: { disabled_providers: ["anthropic"] } }, ) +it.instance( + "policies deny provider use", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const providers = yield* list + expect(providers[ProviderID.anthropic]).toBeUndefined() + }), + { config: denyAnthropicPolicyConfig }, +) + it.instance( "enabled_providers restricts to only listed providers", Effect.gen(function* () { diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 19d8f6f42c..33e96d1217 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -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( diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 110c8ce919..78b3587817 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -259,6 +259,7 @@ export default defineConfig({ "commands", "formatters", "permissions", + "policies", "lsp", "mcp-servers", "acp", diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 3cb9d93748..4ce56576e4 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -393,6 +393,27 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo --- +### Policies + +Use the `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", + "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. diff --git a/packages/web/src/content/docs/policies.mdx b/packages/web/src/content/docs/policies.mdx new file mode 100644 index 0000000000..ff31442355 --- /dev/null +++ b/packages/web/src/content/docs/policies.mdx @@ -0,0 +1,127 @@ +--- +title: Policies +description: Control which configured resources OpenCode may use. +--- + +Policies control whether OpenCode may perform an action on a named resource. They are configured with the `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", + "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", + "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", + "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" +{ + "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" +{ + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "anthropic" }, + { "effect": "allow", "action": "provider.use", "resource": "openai" } + ] +} +``` diff --git a/specs/v2/config.md b/specs/v2/config.md new file mode 100644 index 0000000000..8db0484630 --- /dev/null +++ b/specs/v2/config.md @@ -0,0 +1,169 @@ +# V2 Config Review + +This document breaks the legacy configuration schema into small review groups. Work through one group at a time and decide whether each field should be ported as-is, removed, or redesigned for v2. + +## Status Labels + +- `pending`: not discussed yet +- `keep`: port with substantially the existing meaning +- `remove`: do not carry forward +- `redesign`: keep the capability with a different shape, scope, or owning module + +## Schema Scope + +Use one v2 config schema for now. Some fields, such as `autoupdate`, are intended for global/user configuration, but there is not yet enough benefit to enforce that with separate global and location schemas. Revisit this if more scope-sensitive fields survive the review. + +## Group 1: File Metadata + +Small fields describing the config file itself rather than application behavior. + +| Field | Current Purpose | Status | Notes | +| --------- | ---------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------- | +| `$schema` | JSON schema reference for editor validation and completion | keep | Keep as read-only metadata; loading config must not insert it or create files for it. | + +## Group 2: Process And Server Settings + +Settings that affect process startup, shell execution, or network serving. Review global-only versus location-specific scope carefully. + +| Field | Current Purpose | Status | Notes | +| ------------ | --------------------------------------------------- | ------ | ------------------------------------------------------------------------------ | +| `shell` | Default shell for terminal and shell tool execution | keep | Port as effective config; shared shell choice is used throughout opencode. | +| `logLevel` | Intended logging level configuration | remove | Do not port: no config consumer exists and logging initializes from CLI input. | +| `server` | Hostname, port, mDNS, and CORS settings | remove | Do not port: location config is loaded after the server is already running. | +| `autoupdate` | Automatic update or notification behavior | keep | Global-only user preference; keep `true`, `false`, and `"notify"`. | + +## Group 3: Commands And Project Resources + +Configuration that introduces location-scoped project resources or discoverable content. + +| Field | Current Purpose | Status | Notes | +| -------------- | --------------------------------------- | ------- | ----- | +| `command` | User-defined commands | pending | | +| `skills` | Additional skill locations | pending | | +| `reference` | Named git or local directory references | pending | | +| `instructions` | Additional instruction file patterns | pending | | + +## Group 4: Plugins + +Plugin loading has source-path and scope-sensitive behavior, so it should be reviewed separately from other project resources. + +| Field | Current Purpose | Status | Notes | +| -------- | ----------------------------- | ------- | ------------------------------------------------------ | +| `plugin` | User-specified plugin modules | pending | Existing loader records origin and global/local scope. | + +## Group 5: Filesystem And Tool Runtime + +Settings controlling local file observation, snapshots, language tooling, and tool output behavior. + +| Field | Current Purpose | Status | Notes | +| ------------- | --------------------------------------- | ------- | ----- | +| `watcher` | Ignore patterns for filesystem watching | pending | | +| `snapshot` | Enable filesystem snapshot tracking | pending | | +| `formatter` | Configure formatters | pending | | +| `lsp` | Configure language servers | pending | | +| `attachment` | Configure attachment/image processing | pending | | +| `tool_output` | Configure tool output truncation limits | pending | | + +## Group 6: Sharing And Identity + +Settings affecting sharing behavior or user/account identity rather than model execution. + +| Field | Current Purpose | Status | Notes | +| ------------ | ----------------------------------------------- | ------- | ------------------------------- | +| `share` | Session sharing behavior | pending | | +| `autoshare` | Legacy automatic sharing flag | pending | Deprecated in favor of `share`. | +| `enterprise` | Enterprise URL configuration | pending | | +| `username` | Display username in conversations and telemetry | pending | | + +## Group 7: Providers And Model Selection + +Provider catalog customization and model-choice configuration. The new core work has started here. + +| Field | Current Purpose | Status | Notes | +| -------------------- | ------------------------------------------------- | -------- | --------------------------------------------------------------------------------------- | +| `provider` | Custom provider configuration and model overrides | pending | New core schema currently uses `providers`; decide public key compatibility. | +| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. | +| `enabled_providers` | Restrict enabled providers to an allowlist | redesign | Replace with ordered `provider.use` allow/deny statements and wildcard resources. | +| `model` | Default model selection | pending | | +| `small_model` | Small/utility model selection | pending | | + +Provider selection rules belong in a plural `policies` array rather than provider entries or repeated top-level provider fields. Initial proposed shape: + +```jsonc +{ + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "*", + }, + { + "effect": "allow", + "action": "provider.use", + "resource": "anthropic", + }, + ], +} +``` + +See [provider-policy.md](./provider-policy.md) for the provider policy semantics and precedence rules. + +Policy evaluation will consume authored config documents in reverse order while preserving statement order inside each document. The precedence of `.opencode` policy sources remains open until `.opencode` configuration is reviewed. + +## Group 8: Agents And Permissions + +Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices. + +| Field | Current Purpose | Status | Notes | +| --------------- | --------------------------------------------------- | ------- | ------------------------------------------- | +| `default_agent` | Choose default primary agent | pending | | +| `mode` | Legacy agent configuration alias | pending | Deprecated in favor of `agent`. | +| `agent` | Configure primary, subagent, and specialized agents | pending | | +| `permission` | Tool permission rules | pending | | +| `tools` | Legacy tool enable/disable map | pending | Converted to permissions by current loader. | + +## Group 9: Integrations + +External protocol and server integration configuration. + +| Field | Current Purpose | Status | Notes | +| ----- | ------------------------------------- | ------- | ----- | +| `mcp` | MCP server definitions and enablement | pending | | + +## Group 10: Conversation Lifecycle + +Behavior affecting long-running conversations and context management. + +| Field | Current Purpose | Status | Notes | +| ------------ | ----------------------------------------------------------- | ------- | ----- | +| `compaction` | Automatic compaction, pruning, and context reserve settings | pending | | + +## Group 11: Deprecated And Experimental Settings + +Fields that should not be ported by inertia; each needs an explicit justification. + +| Field | Current Purpose | Status | Notes | +| ------------------------------------ | --------------------------------------- | ------- | ------------------------------------------------------------------- | +| `layout` | Legacy layout selection | pending | Deprecated; current description says stretch layout is always used. | +| `experimental.disable_paste_summary` | Disable pasted-content summary behavior | pending | | +| `experimental.batch_tool` | Enable batch tool | pending | | +| `experimental.openTelemetry` | Enable AI SDK telemetry spans | pending | | +| `experimental.primary_tools` | Restrict tools to primary agents | pending | | +| `experimental.continue_loop_on_deny` | Continue loop after denied tool call | pending | | +| `experimental.mcp_timeout` | MCP request timeout | pending | May belong with MCP rather than experiments. | + +## Review Order + +Work through the groups in this order unless a dependency between decisions becomes clear: + +1. File Metadata +2. Process And Server Settings +3. Providers And Model Selection +4. Commands And Project Resources +5. Plugins +6. Filesystem And Tool Runtime +7. Sharing And Identity +8. Agents And Permissions +9. Integrations +10. Conversation Lifecycle +11. Deprecated And Experimental Settings diff --git a/specs/v2/provider-policy.md b/specs/v2/provider-policy.md new file mode 100644 index 0000000000..afab09e073 --- /dev/null +++ b/specs/v2/provider-policy.md @@ -0,0 +1,275 @@ +# Policy + +## Purpose + +Policies control whether an operation on a named resource is allowed. They may be authored in configuration files, but policy evaluation is its own runtime concern. + +The first policy consumer is provider availability: + +```text +action: provider.use +resource: provider ID, such as openai or company-ai +``` + +Provider configuration and provider policy remain separate: + +- `providers` describes endpoints, options, and model overrides. +- `policies` determines whether an operation using a provider is allowed. + +A provider can be correctly configured and have valid credentials while policy still denies its use. + +## Goals + +- Replace legacy `enabled_providers` and `disabled_providers`. +- Keep the default experience unchanged when users specify no policy. +- Support wildcard matching for actions and resources. +- Provide one small policy vocabulary that can later cover operations such as `plugin.load` or `mcp.connect`. +- Let user policy override repository policy, and later allow organization-managed policy to override both. +- Keep evaluation simple: matching statements are applied in order and the last match wins. + +## Non-Goals + +- Policies do not configure endpoints, credentials, models, or provider options. +- Policies do not make unusable resources usable. +- Policies do not currently provide conditions, principals, approval prompts, or enforced configuration values. +- This spec does not define how organization-managed policies are delivered. + +## Statement Shape + +```jsonc +{ + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "openai", + }, + ], +} +``` + +```ts +interface PolicyInfo { + effect: "allow" | "deny" + action: string + resource: string +} +``` + +The `Policy` module owns the shared `Policy.Info` interface, `Policy.Effect` type, and evaluator. Domains define their supported typed statement schemas; for example, `Catalog.ProviderPolicy` fixes `action` to `"provider.use"`. The config schema gathers those domain-defined statement schemas into the accepted `policies` union because config files are one place statements can be authored. + +## Matching + +Both `action` and `resource` use opencode's existing wildcard matching behavior. + +Examples: + +| Action | Resource | Matches | +| -------------- | ----------- | ---------------------------------------------------------------------------- | +| `provider.use` | `openai` | Only use of provider ID `openai` | +| `provider.use` | `company-*` | Use of provider IDs such as `company-us` and `company-eu` | +| `provider.*` | `*` | Any provider operation on any provider, if more actions are introduced later | + +No pattern-specific precedence exists. A specific resource does not automatically beat a wildcard resource. Written/evaluation order controls the result. + +## Evaluation + +To evaluate an operation and resource: + +1. Start with `allow`. +2. Consider every statement whose `action` and `resource` match the requested action and resource. +3. Each matching statement replaces the current decision with its `effect`. +4. The last matching statement determines the result. + +Conceptually: + +```ts +function evaluate(action: string, resource: string, fallback: Policy.Effect, statements: Policy.Info[]) { + return ( + statements.findLast( + (statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource), + )?.effect ?? fallback + ) +} +``` + +Each caller supplies the default effect appropriate for its operation. Catalog provider use supplies `"allow"`, so no provider policy statements means normal behavior continues: otherwise usable providers are allowed. + +## Ordering Within One Config Document + +Statements remain in the order written by the user. + +To deny all providers except Anthropic: + +```jsonc +{ + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "*", + }, + { + "effect": "allow", + "action": "provider.use", + "resource": "anthropic", + }, + ], +} +``` + +Result: + +```text +provider.use / anthropic -> allow +provider.use / openai -> deny +``` + +To allow internal providers except experimental ones: + +```jsonc +{ + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "company-*" }, + { "effect": "deny", "action": "provider.use", "resource": "company-experimental-*" }, + ], +} +``` + +Result: + +```text +company-stable: allowed +company-experimental-fast: denied +openai: denied +``` + +## Ordering Across Authored Config Documents + +Ordinary settings and policies have different precedence needs: + +- Ordinary settings are read forward, so location-specific settings override user-global settings. +- Policies are read by reversing authored config documents, so user-global policy can override repository policy. +- Statements inside each document keep their written order. + +At minimum, this means a repository cannot silently re-enable something the user denied globally. + +Project config: + +```jsonc +{ + "policies": [{ "effect": "allow", "action": "provider.use", "resource": "openai" }], +} +``` + +User-global config: + +```jsonc +{ + "policies": [{ "effect": "deny", "action": "provider.use", "resource": "openai" }], +} +``` + +Result: + +```text +provider.use / openai -> deny +``` + +The relative policy precedence of direct project files and `.opencode` files is intentionally deferred until `.opencode` configuration is reviewed. + +## Organization-Managed Policy + +Organization-managed policy is not ordinary authored config. When implemented, managed statements must be appended after the reversed authored statements so they have final authority. + +```text +repository policy -> user-global policy -> organization-managed policy +``` + +Plugins must not be allowed to add, remove, or override policy statements. Plugins can contribute functionality or configured providers; policy determines whether opencode permits an operation through its managed execution paths. + +Provider policy is not a full sandbox for executable plugins. A denied provider must not be usable through the normal provider/model path, but arbitrary plugin code requires separate governance if that becomes a compliance requirement. + +## Interaction With Provider Configuration + +```jsonc +{ + "providers": { + "company-ai": { + "endpoint": { + "type": "openai/responses", + "url": "https://ai.company.example/v1/responses", + }, + }, + }, + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "company-ai" }, + ], +} +``` + +The provider entry configures `company-ai`; the policy statements make it the only provider permitted for use. + +Provider policy applies regardless of how a provider becomes known or usable, including: + +- models.dev catalog data +- environment credentials +- saved accounts +- built-in provider plugins +- explicit provider configuration + +## Applying Provider Policy + +Provider records and model overrides should be assembled before checking provider policy. Otherwise later provider loading could recreate a provider that was already filtered. + +Intended flow: + +1. Build provider/model catalog entries. +2. Apply configured provider and model overrides. +3. Ask `Policy.Service` to evaluate `provider.use` for each provider ID. +4. Prevent denied providers from being selectable or used. + +Whether denied providers are removed entirely or retained as disabled records for diagnostics remains an implementation decision. + +## Legacy Migration + +Legacy deny list: + +```jsonc +{ + "disabled_providers": ["openai", "google"], +} +``` + +Equivalent v2 policy: + +```jsonc +{ + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "openai" }, + { "effect": "deny", "action": "provider.use", "resource": "google" }, + ], +} +``` + +Legacy allowlist: + +```jsonc +{ + "enabled_providers": ["anthropic", "openai"], +} +``` + +Equivalent v2 policy: + +```jsonc +{ + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "anthropic" }, + { "effect": "allow", "action": "provider.use", "resource": "openai" }, + ], +} +``` From 4cd69df7c80a9013a0b41ef014178adb65ca09fe Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 27 May 2026 22:16:46 -0400 Subject: [PATCH 03/19] feat(core): mark policies experimental --- packages/core/src/config/config.ts | 2 +- packages/core/src/config/schema.ts | 6 +- packages/core/test/config/config.test.ts | 8 +- packages/opencode/src/config/config.ts | 6 +- .../opencode/test/provider/provider.test.ts | 6 +- packages/web/src/content/docs/config.mdx | 18 ++-- packages/web/src/content/docs/policies.mdx | 82 +++++++++------- specs/v2/config.md | 30 +++--- specs/v2/provider-policy.md | 98 +++++++++++-------- 9 files changed, 146 insertions(+), 110 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 273c24679d..2afa9a91ba 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -82,7 +82,7 @@ export const layer = Layer.effect( 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.policies ?? [])) + yield* policy.load(configs.toReversed().flatMap((config) => config.info.experimental?.policies ?? [])) return Service.of({ directories: Effect.fn("Config.directories")(function* () { diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index b308bcabfe..e69f6161bb 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -14,6 +14,10 @@ export class Policy extends Schema.Class("ConfigV2.Policy")({ action: PolicyAction, }) {} +export class Experimental extends Schema.Class("ConfigV2.Experimental")({ + policies: Policy.pipe(Schema.Array, Schema.optional), +}) {} + export class Info extends Schema.Class("ConfigV2.Info")({ $schema: Schema.optional(Schema.String).annotate({ description: "JSON schema reference for configuration validation", @@ -21,7 +25,7 @@ export class Info extends Schema.Class("ConfigV2.Info")({ shell: Schema.String.pipe(Schema.optional).annotate({ description: "Default shell to use for terminal and shell tool execution", }), - policies: Policy.pipe(Schema.Array, Schema.optional), + experimental: Experimental.pipe(Schema.optional), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), }) {} diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 9c9063aa53..a376c78211 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -132,7 +132,7 @@ describe("Config", () => { const file = path.join(tmp.path, "opencode.json") const contents = JSON.stringify({ shell: "/bin/zsh", - policies: [{ effect: "deny", action: "provider.use", resource: "openai" }], + experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }, providers: { local: provider }, }) yield* Effect.promise(() => fs.writeFile(file, contents)) @@ -143,7 +143,7 @@ describe("Config", () => { expect(documents[0]?.info.$schema).toBeUndefined() expect(documents[0]?.info.shell).toBe("/bin/zsh") - expect(documents[0]?.info.policies?.[0]).toEqual({ + expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({ effect: "deny", action: "provider.use", resource: "openai", @@ -223,11 +223,11 @@ describe("Config", () => { await fs.mkdir(global, { recursive: true }) await fs.writeFile( path.join(global, "opencode.json"), - JSON.stringify({ policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] }), + JSON.stringify({ experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] } }), ) await fs.writeFile( path.join(tmp.path, "opencode.json"), - JSON.stringify({ policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] }), + JSON.stringify({ experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] } }), ) }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index dd8d872c9d..0bf23b3c68 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -178,9 +178,6 @@ export const Info = Schema.Struct({ enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "When set, ONLY these providers will be enabled. All other providers will be ignored", }), - policies: Schema.optional(Schema.mutable(Schema.Array(ConfigV2.Policy))).annotate({ - description: "Policy statements applied to supported resources, such as provider access", - }), model: Schema.optional(ConfigModelID).annotate({ description: "Model to use in the format of provider/model, eg anthropic/claude-2", }), @@ -305,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(ConfigV2.Policy))).annotate({ + description: "Policy statements applied to supported resources, such as provider access", + }), }), ), }).annotate({ identifier: "Config" }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 62c25bae9a..60ab677b3e 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -104,7 +104,9 @@ const alphaProviderConfig = { const denyAnthropicPolicyConfig = { provider: {}, - policies: [{ effect: "deny" as const, action: "provider.use" as const, resource: "anthropic" }], + experimental: { + policies: [{ effect: "deny" as const, action: "provider.use" as const, resource: "anthropic" }], + }, } it.instance("provider loaded from env variable", () => @@ -139,7 +141,7 @@ it.instance( ) it.instance( - "policies deny provider use", + "experimental policies deny provider use", Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") const providers = yield* list diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 4ce56576e4..ff9e348678 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -395,18 +395,20 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo ### Policies -Use the `policies` option to allow or deny OpenCode actions on configured resources. Currently, policies can control which providers OpenCode may use. +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", - "policies": [ - { - "effect": "deny", - "action": "provider.use", - "resource": "openai" - } - ] + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "openai" + } + ] + } } ``` diff --git a/packages/web/src/content/docs/policies.mdx b/packages/web/src/content/docs/policies.mdx index ff31442355..0f059562e2 100644 --- a/packages/web/src/content/docs/policies.mdx +++ b/packages/web/src/content/docs/policies.mdx @@ -3,7 +3,7 @@ title: Policies description: Control which configured resources OpenCode may use. --- -Policies control whether OpenCode may perform an action on a named resource. They are configured with the `policies` array in `opencode.json`. +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. @@ -22,13 +22,15 @@ For example, deny use of the `openai` provider: ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "policies": [ - { - "effect": "deny", - "action": "provider.use", - "resource": "openai" - } - ] + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "openai" + } + ] + } } ``` @@ -55,13 +57,15 @@ The `resource` field supports wildcard matching. Use `*` to match zero or more c ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "policies": [ - { - "effect": "deny", - "action": "provider.use", - "resource": "company-*" - } - ] + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "company-*" + } + ] + } } ``` @@ -78,18 +82,20 @@ For example, allow only Anthropic: ```json title="opencode.json" { "$schema": "https://opencode.ai/config.json", - "policies": [ - { - "effect": "deny", - "action": "provider.use", - "resource": "*" - }, - { - "effect": "allow", - "action": "provider.use", - "resource": "anthropic" - } - ] + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "*" + }, + { + "effect": "allow", + "action": "provider.use", + "resource": "anthropic" + } + ] + } } ``` @@ -107,10 +113,12 @@ To replace `disabled_providers`: ```json title="opencode.json" { - "policies": [ - { "effect": "deny", "action": "provider.use", "resource": "openai" }, - { "effect": "deny", "action": "provider.use", "resource": "google" } - ] + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "openai" }, + { "effect": "deny", "action": "provider.use", "resource": "google" } + ] + } } ``` @@ -118,10 +126,12 @@ To replace `enabled_providers`, deny all providers first and allow the selected ```json title="opencode.json" { - "policies": [ - { "effect": "deny", "action": "provider.use", "resource": "*" }, - { "effect": "allow", "action": "provider.use", "resource": "anthropic" }, - { "effect": "allow", "action": "provider.use", "resource": "openai" } - ] + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "anthropic" }, + { "effect": "allow", "action": "provider.use", "resource": "openai" } + ] + } } ``` diff --git a/specs/v2/config.md b/specs/v2/config.md index 8db0484630..74e896dbdf 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -82,27 +82,29 @@ Provider catalog customization and model-choice configuration. The new core work | Field | Current Purpose | Status | Notes | | -------------------- | ------------------------------------------------- | -------- | --------------------------------------------------------------------------------------- | | `provider` | Custom provider configuration and model overrides | pending | New core schema currently uses `providers`; decide public key compatibility. | -| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. | +| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `experimental.policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. | | `enabled_providers` | Restrict enabled providers to an allowlist | redesign | Replace with ordered `provider.use` allow/deny statements and wildcard resources. | | `model` | Default model selection | pending | | | `small_model` | Small/utility model selection | pending | | -Provider selection rules belong in a plural `policies` array rather than provider entries or repeated top-level provider fields. Initial proposed shape: +Provider selection rules belong in `experimental.policies` rather than provider entries or repeated top-level provider fields. Initial proposed shape: ```jsonc { - "policies": [ - { - "effect": "deny", - "action": "provider.use", - "resource": "*", - }, - { - "effect": "allow", - "action": "provider.use", - "resource": "anthropic", - }, - ], + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "*", + }, + { + "effect": "allow", + "action": "provider.use", + "resource": "anthropic", + }, + ], + }, } ``` diff --git a/specs/v2/provider-policy.md b/specs/v2/provider-policy.md index afab09e073..6e3fc75ba1 100644 --- a/specs/v2/provider-policy.md +++ b/specs/v2/provider-policy.md @@ -14,7 +14,7 @@ resource: provider ID, such as openai or company-ai Provider configuration and provider policy remain separate: - `providers` describes endpoints, options, and model overrides. -- `policies` determines whether an operation using a provider is allowed. +- `experimental.policies` determines whether an operation using a provider is allowed. A provider can be correctly configured and have valid credentials while policy still denies its use. @@ -38,13 +38,15 @@ A provider can be correctly configured and have valid credentials while policy s ```jsonc { - "policies": [ - { - "effect": "deny", - "action": "provider.use", - "resource": "openai", - }, - ], + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "openai", + }, + ], + }, } ``` @@ -56,7 +58,7 @@ interface PolicyInfo { } ``` -The `Policy` module owns the shared `Policy.Info` interface, `Policy.Effect` type, and evaluator. Domains define their supported typed statement schemas; for example, `Catalog.ProviderPolicy` fixes `action` to `"provider.use"`. The config schema gathers those domain-defined statement schemas into the accepted `policies` union because config files are one place statements can be authored. +The `Policy` module owns the shared `Policy.Info` interface, `Policy.Effect` type, and evaluator. Domains define their supported typed statement schemas; for example, `Catalog.ProviderPolicy` fixes `action` to `"provider.use"`. The config schema gathers those domain-defined statement schemas into the accepted `experimental.policies` union because config files are one place statements can be authored while the capability is experimental. ## Matching @@ -103,18 +105,20 @@ To deny all providers except Anthropic: ```jsonc { - "policies": [ - { - "effect": "deny", - "action": "provider.use", - "resource": "*", - }, - { - "effect": "allow", - "action": "provider.use", - "resource": "anthropic", - }, - ], + "experimental": { + "policies": [ + { + "effect": "deny", + "action": "provider.use", + "resource": "*", + }, + { + "effect": "allow", + "action": "provider.use", + "resource": "anthropic", + }, + ], + }, } ``` @@ -129,11 +133,13 @@ To allow internal providers except experimental ones: ```jsonc { - "policies": [ - { "effect": "deny", "action": "provider.use", "resource": "*" }, - { "effect": "allow", "action": "provider.use", "resource": "company-*" }, - { "effect": "deny", "action": "provider.use", "resource": "company-experimental-*" }, - ], + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "company-*" }, + { "effect": "deny", "action": "provider.use", "resource": "company-experimental-*" }, + ], + }, } ``` @@ -159,7 +165,9 @@ Project config: ```jsonc { - "policies": [{ "effect": "allow", "action": "provider.use", "resource": "openai" }], + "experimental": { + "policies": [{ "effect": "allow", "action": "provider.use", "resource": "openai" }], + }, } ``` @@ -167,7 +175,9 @@ User-global config: ```jsonc { - "policies": [{ "effect": "deny", "action": "provider.use", "resource": "openai" }], + "experimental": { + "policies": [{ "effect": "deny", "action": "provider.use", "resource": "openai" }], + }, } ``` @@ -203,10 +213,12 @@ Provider policy is not a full sandbox for executable plugins. A denied provider }, }, }, - "policies": [ - { "effect": "deny", "action": "provider.use", "resource": "*" }, - { "effect": "allow", "action": "provider.use", "resource": "company-ai" }, - ], + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "company-ai" }, + ], + }, } ``` @@ -247,10 +259,12 @@ Equivalent v2 policy: ```jsonc { - "policies": [ - { "effect": "deny", "action": "provider.use", "resource": "openai" }, - { "effect": "deny", "action": "provider.use", "resource": "google" }, - ], + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "openai" }, + { "effect": "deny", "action": "provider.use", "resource": "google" }, + ], + }, } ``` @@ -266,10 +280,12 @@ Equivalent v2 policy: ```jsonc { - "policies": [ - { "effect": "deny", "action": "provider.use", "resource": "*" }, - { "effect": "allow", "action": "provider.use", "resource": "anthropic" }, - { "effect": "allow", "action": "provider.use", "resource": "openai" }, - ], + "experimental": { + "policies": [ + { "effect": "deny", "action": "provider.use", "resource": "*" }, + { "effect": "allow", "action": "provider.use", "resource": "anthropic" }, + { "effect": "allow", "action": "provider.use", "resource": "openai" }, + ], + }, } ``` From c925ba2b68e5551624db20955338421eedfc8970 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 27 May 2026 23:11:56 -0400 Subject: [PATCH 04/19] feat(core): expand v2 config schema --- packages/core/src/{config => }/config.ts | 78 +++++++++++++++--- packages/core/src/config/experimental.ts | 18 +++++ packages/core/src/config/plugin.ts | 13 +++ packages/core/src/config/provider.ts | 2 +- packages/core/src/config/reference.ts | 17 ++++ packages/core/src/config/schema.ts | 47 ----------- packages/core/src/config/watcher.ts | 7 ++ packages/core/src/location-layer.ts | 2 +- packages/core/src/plugin/boot.ts | 2 +- packages/core/test/config/config.test.ts | 48 +++++++++-- packages/core/test/config/provider.test.ts | 17 ++-- packages/opencode/src/config/config.ts | 4 +- specs/v2/config.md | 93 +++++++++++++++++----- 13 files changed, 251 insertions(+), 97 deletions(-) rename packages/core/src/{config => }/config.ts (55%) create mode 100644 packages/core/src/config/experimental.ts create mode 100644 packages/core/src/config/plugin.ts create mode 100644 packages/core/src/config/reference.ts delete mode 100644 packages/core/src/config/schema.ts create mode 100644 packages/core/src/config/watcher.ts diff --git a/packages/core/src/config/config.ts b/packages/core/src/config.ts similarity index 55% rename from packages/core/src/config/config.ts rename to packages/core/src/config.ts index 2afa9a91ba..77c8a98e27 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config.ts @@ -3,18 +3,74 @@ 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 { Policy } from "../policy" -import { AbsolutePath } from "../schema" -import { ConfigV2 } from "./schema" +import { AppFileSystem } from "./filesystem" +import { Global } from "./global" +import { Location } from "./location" +import { Policy } from "./policy" +import { AbsolutePath } from "./schema" +import { ConfigExperimental } from "./config/experimental" +import { ConfigPlugin } from "./config/plugin" +import { ConfigProvider } from "./config/provider" +import { ConfigReference } from "./config/reference" +import { ConfigWatcher } from "./config/watcher" + +export class Info extends Schema.Class("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", + }), + 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", + }), + 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 class FileSource extends Schema.Class("Config.FileSource")({ + type: Schema.Literal("file"), + path: Schema.String, +}) {} + +export class MemorySource extends Schema.Class("Config.MemorySource")({ + type: Schema.Literal("memory"), +}) {} + +export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type")) +export type Source = typeof Source.Type + +export class Loaded extends Schema.Class("Config.Loaded")({ + source: Source, + info: Info, +}) {} export interface Interface { /** Returns supplemental config directories from lowest to highest priority. */ readonly directories: () => Effect.Effect /** Loads location config files from lowest to highest priority. */ - readonly get: () => Effect.Effect + readonly get: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Config") {} @@ -39,15 +95,15 @@ export const layer = Layer.effect( // Accept legacy fields while v2 is migrated incrementally; recognized // fields still have to satisfy the v2 schema. const info = Option.getOrUndefined( - Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all", onExcessProperty: "ignore" }), + Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }), ) if (!info) return - return new ConfigV2.Loaded({ source: new ConfigV2.FileSource({ type: "file", path: filepath }), info }) + return new Loaded({ source: new FileSource({ 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 ConfigV2.Loaded => config !== undefined)), + Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)), ) }) @@ -74,7 +130,7 @@ export const layer = Layer.effect( .pipe(Effect.orDie)).toReversed() const direct = yield* Effect.forEach(directPaths, loadFile).pipe( Effect.orDie, - Effect.map((configs) => configs.filter((config): config is ConfigV2.Loaded => config !== undefined)), + 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: diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts new file mode 100644 index 0000000000..377ed26ade --- /dev/null +++ b/packages/core/src/config/experimental.ts @@ -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("Config.Experimental.Policy")({ + ...PolicyV2.Info.fields, + action: PolicyAction, +}) {} + +export class Experimental extends Schema.Class("Config.Experimental")({ + policies: Policy.pipe(Schema.Array, Schema.optional), +}) {} diff --git a/packages/core/src/config/plugin.ts b/packages/core/src/config/plugin.ts new file mode 100644 index 0000000000..4268a7f79d --- /dev/null +++ b/packages/core/src/config/plugin.ts @@ -0,0 +1,13 @@ +export * as ConfigPlugin from "./plugin" + +import { Schema } from "effect" + +export class Entry extends Schema.Class("Config.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) diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index 47d839302c..d4c6d0848e 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -2,7 +2,7 @@ export * as ConfigProvider from "./provider" import { Effect, Schema } from "effect" import { Catalog } from "../catalog" -import { Config } from "./config" +import { Config } from "../config" import { ProviderV2 } from "../provider" import { ModelV2 } from "../model" import { PluginV2 } from "../plugin" diff --git a/packages/core/src/config/reference.ts b/packages/core/src/config/reference.ts new file mode 100644 index 0000000000..55878bfab0 --- /dev/null +++ b/packages/core/src/config/reference.ts @@ -0,0 +1,17 @@ +export * as ConfigReference from "./reference" + +import { Schema } from "effect" + +export class Git extends Schema.Class("Config.Reference.Git")({ + repository: Schema.String, + branch: Schema.String.pipe(Schema.optional), +}) {} + +export class Local extends Schema.Class("Config.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) diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts deleted file mode 100644 index e69f6161bb..0000000000 --- a/packages/core/src/config/schema.ts +++ /dev/null @@ -1,47 +0,0 @@ -export * as ConfigV2 from "./schema" - -import { Schema } from "effect" -import { Catalog } from "../catalog" -import { Policy as PolicyV2 } from "../policy" -import { ConfigProvider } from "./provider" - -// 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("ConfigV2.Policy")({ - ...PolicyV2.Info.fields, - action: PolicyAction, -}) {} - -export class Experimental extends Schema.Class("ConfigV2.Experimental")({ - policies: Policy.pipe(Schema.Array, Schema.optional), -}) {} - -export class Info extends Schema.Class("ConfigV2.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", - }), - experimental: Experimental.pipe(Schema.optional), - providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), -}) {} - -export class FileSource extends Schema.Class("ConfigV2.FileSource")({ - type: Schema.Literal("file"), - path: Schema.String, -}) {} - -export class MemorySource extends Schema.Class("ConfigV2.MemorySource")({ - type: Schema.Literal("memory"), -}) {} - -export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type")) -export type Source = typeof Source.Type - -export class Loaded extends Schema.Class("ConfigV2.Loaded")({ - source: Source, - info: Info, -}) {} diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts new file mode 100644 index 0000000000..be5c91a9bf --- /dev/null +++ b/packages/core/src/config/watcher.ts @@ -0,0 +1,7 @@ +export * as ConfigWatcher from "./watcher" + +import { Schema } from "effect" + +export class Info extends Schema.Class("Config.Watcher")({ + ignore: Schema.String.pipe(Schema.Array, Schema.optional), +}) {} diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 905206fd63..67293f7c5f 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -3,7 +3,7 @@ import { Location } from "./location" import { Catalog } from "./catalog" import { PluginBoot } from "./plugin/boot" import { Policy } from "./policy" -import { Config } from "./config/config" +import { Config } from "./config" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index e6c3ac74a1..c8c8b4061a 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -3,7 +3,7 @@ export * as PluginBoot from "./boot" import { Context, Deferred, Effect, Layer } from "effect" import { AccountV2 } from "../account" import { Catalog } from "../catalog" -import { Config } from "../config/config" +import { Config } from "../config" import { ConfigProvider } from "../config/provider" import { EventV2 } from "../event" import { Npm } from "../npm" diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index a376c78211..a6f6489e17 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -2,9 +2,8 @@ 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/config" +import { Config } from "@opencode-ai/core/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" -import { ConfigV2 } from "@opencode-ai/core/config/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" @@ -105,8 +104,8 @@ describe("Config", () => { 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(ConfigV2.Loaded) - expect(documents[0]?.source).toBeInstanceOf(ConfigV2.FileSource) + expect(documents[0]).toBeInstanceOf(Config.Loaded) + expect(documents[0]?.source).toBeInstanceOf(Config.FileSource) expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe( path.join(tmp.path, "config.json"), ) @@ -155,7 +154,7 @@ describe("Config", () => { ), ) - it.live("loads recognized v2 fields from config files that still contain legacy fields", () => + it.live("loads supported scalar and resource configuration", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -168,8 +167,20 @@ describe("Config", () => { JSON.stringify({ shell: "/bin/bash", model: "anthropic/claude", - disabled_providers: ["openai"], - server: { port: 4096 }, + autoupdate: "notify", + snapshots: false, + watcher: { ignore: ["node_modules/**", "dist/**", ".git"] }, + 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" } }, + ], }), ), ) @@ -180,6 +191,29 @@ describe("Config", () => { 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.snapshots).toBe(false) + expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] }) + 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))) }), ), diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 6f9b3181f2..ed1f18b7d7 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -1,9 +1,8 @@ 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/config" +import { Config } from "@opencode-ai/core/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" -import { ConfigV2 } from "@opencode-ai/core/config/schema" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -21,7 +20,7 @@ function options(headers: Record, variant?: string) { } } -const decode = Schema.decodeUnknownSync(ConfigV2.Info) +const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProvider.Plugin", () => { it.effect("loads configured providers and applies later model overrides", () => @@ -34,8 +33,8 @@ describe("ConfigProvider.Plugin", () => { directories: () => Effect.succeed([]), get: () => Effect.succeed([ - new ConfigV2.Loaded({ - source: new ConfigV2.MemorySource({ type: "memory" }), + new Config.Loaded({ + source: new Config.MemorySource({ type: "memory" }), info: decode({ providers: { custom: { @@ -63,8 +62,8 @@ describe("ConfigProvider.Plugin", () => { }, }), }), - new ConfigV2.Loaded({ - source: new ConfigV2.MemorySource({ type: "memory" }), + new Config.Loaded({ + source: new Config.MemorySource({ type: "memory" }), info: decode({ providers: { custom: { @@ -95,8 +94,8 @@ describe("ConfigProvider.Plugin", () => { }, }), }), - new ConfigV2.Loaded({ - source: new ConfigV2.MemorySource({ type: "memory" }), + new Config.Loaded({ + source: new Config.MemorySource({ type: "memory" }), info: decode({ providers: { custom: { name: "Renamed" }, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0bf23b3c68..8dc8c6ee54 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -43,7 +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 { ConfigV2 } from "@opencode-ai/core/config/schema" +import { ConfigExperimental } from "@opencode-ai/core/config/experimental" const log = Log.create({ service: "config" }) @@ -302,7 +302,7 @@ 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(ConfigV2.Policy))).annotate({ + policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({ description: "Policy statements applied to supported resources, such as provider access", }), }), diff --git a/specs/v2/config.md b/specs/v2/config.md index 74e896dbdf..196c3fc4b3 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -36,20 +36,71 @@ Settings that affect process startup, shell execution, or network serving. Revie Configuration that introduces location-scoped project resources or discoverable content. -| Field | Current Purpose | Status | Notes | -| -------------- | --------------------------------------- | ------- | ----- | -| `command` | User-defined commands | pending | | -| `skills` | Additional skill locations | pending | | -| `reference` | Named git or local directory references | pending | | -| `instructions` | Additional instruction file patterns | pending | | +| Field | Current Purpose | Status | Notes | +| -------------- | --------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------- | +| `command` | User-defined commands | remove | Do not port as v2 config; named reusable user workflows belong to skills. | +| `skills` | Additional skill locations | redesign | Replace `{ paths?, urls? }` with a single array of local path or remote URL discovery sources. | +| `reference` | Named git or local directory references | redesign | Rename to plural `references`; retain named local path and Git repository external-context entries. | +| `instructions` | Additional ambient instruction sources | keep | Keep as one array of local paths, glob patterns, or remote URLs supplying automatically included context. | + +V2 does not expose separate user-authored command configuration. Skills should cover named reusable prompt workflows, whether invoked directly by the user or loaded by an agent. Internal command routing and built-in commands may remain runtime concerns without creating a `command` or `commands` config field. + +This intentionally does not port legacy command-only behavior such as per-command `model`, `agent`, `subtask`, prompt shell expansion, or positional/template substitution. If a related capability is needed in v2, it should be designed in the owning domain rather than preserved through a second workflow definition system. + +Keep `skills` as discovery-source configuration rather than inline workflow definitions. Skill content remains owned by `SKILL.md`; each `skills` entry is either a local search root or a remote discovery URL. Direct invocation behavior can be designed separately without expanding the config shape. + +```jsonc +{ + "skills": ["./team-skills", "~/shared-skills", "https://example.com/.well-known/skills/"], +} +``` + +Keep ambient instructions separate from skills. Instructions are automatically included as model context, while skills are loaded or invoked intentionally. Each source is unambiguously either a local path/glob or a URL, so v2 keeps the simple array shape: + +```jsonc +{ + "instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"], +} +``` + +Keep named external context references as a v2 configuration capability, renamed to plural `references` because it is a collection keyed by alias. References declare local directories or Git repositories that can later be addressed as `@alias` or `@alias/path` when the v2 runtime implements this behavior. + +```jsonc +{ + "references": { + "design-system": { "path": "../ui-library" }, + "sdk": { "repository": "github.com/example/sdk", "branch": "main" }, + }, +} +``` + +Retain the compact string entry form as well: values starting with `.`, `/`, or `~` represent local paths, and other strings represent Git repositories. ## Group 4: Plugins Plugin loading has source-path and scope-sensitive behavior, so it should be reviewed separately from other project resources. -| Field | Current Purpose | Status | Notes | -| -------- | ----------------------------- | ------- | ------------------------------------------------------ | -| `plugin` | User-specified plugin modules | pending | Existing loader records origin and global/local scope. | +| Field | Current Purpose | Status | Notes | +| -------- | ----------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `plugin` | User-specified plugin modules | redesign | Rename to plural `plugins`; retain ordered loading with package strings or `{ package, options? }` entries. | + +Plugin order remains part of the v2 configuration contract because hook registration and execution can depend on load order. Replace legacy option tuples with readable object entries: + +```jsonc +{ + "plugins": [ + "opencode-helicone-session", + { + "package": "@my-org/audit-plugin", + "options": { + "endpoint": "https://audit.example.com", + }, + }, + ], +} +``` + +The configured `plugins` list represents package-loaded plugins only. Local plugin code remains discovered from plugin directories such as `.opencode/plugins/`; v2 does not port arbitrary configured local paths or file URLs into this field. ## Group 5: Filesystem And Tool Runtime @@ -57,8 +108,8 @@ Settings controlling local file observation, snapshots, language tooling, and to | Field | Current Purpose | Status | Notes | | ------------- | --------------------------------------- | ------- | ----- | -| `watcher` | Ignore patterns for filesystem watching | pending | | -| `snapshot` | Enable filesystem snapshot tracking | pending | | +| `watcher` | Ignore patterns for filesystem watching | keep | Keep `{ ignore?: string[] }`; this configures the filesystem watcher subsystem. | +| `snapshot` | Enable filesystem snapshot tracking | redesign | Rename to plural `snapshots`; controls creation of snapshots used for undo and revert behavior. | | `formatter` | Configure formatters | pending | | | `lsp` | Configure language servers | pending | | | `attachment` | Configure attachment/image processing | pending | | @@ -79,13 +130,13 @@ Settings affecting sharing behavior or user/account identity rather than model e Provider catalog customization and model-choice configuration. The new core work has started here. -| Field | Current Purpose | Status | Notes | -| -------------------- | ------------------------------------------------- | -------- | --------------------------------------------------------------------------------------- | -| `provider` | Custom provider configuration and model overrides | pending | New core schema currently uses `providers`; decide public key compatibility. | -| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `experimental.policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. | -| `enabled_providers` | Restrict enabled providers to an allowlist | redesign | Replace with ordered `provider.use` allow/deny statements and wildcard resources. | -| `model` | Default model selection | pending | | -| `small_model` | Small/utility model selection | pending | | +| Field | Current Purpose | Status | Notes | +| -------------------- | ------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | +| `provider` | Custom provider configuration and model overrides | redesign | Rename to plural `providers` in v2; do not preserve the legacy singular key. Review nested provider/model fields separately. | +| `disabled_providers` | Disable automatically loaded providers | redesign | Replace with `experimental.policies: [{ effect: "deny", action: "provider.use", resource: "..." }]`. | +| `enabled_providers` | Restrict enabled providers to an allowlist | redesign | Replace with ordered `provider.use` allow/deny statements and wildcard resources. | +| `model` | Default model selection | keep | Keep as the fallback model when an active session or agent does not specify a model. | +| `small_model` | Small/utility model selection | remove | Do not port; its only runtime consumer is title generation, which can use an explicit `title` agent model override. | Provider selection rules belong in `experimental.policies` rather than provider entries or repeated top-level provider fields. Initial proposed shape: @@ -112,6 +163,12 @@ See [provider-policy.md](./provider-policy.md) for the provider policy semantics Policy evaluation will consume authored config documents in reverse order while preserving statement order inside each document. The precedence of `.opencode` policy sources remains open until `.opencode` configuration is reviewed. +Provider configuration uses the plural `providers` key in v2. This intentionally differs from the legacy singular `provider` key; v2 does not add a compatibility alias while its configuration surface is still being defined. + +Keep `model` as the default model fallback. It is application-wide behavior used when an active session or agent has no explicit model selection, so it does not belong inside any individual provider configuration. + +Do not port `small_model`. In the current runtime it is only consulted while generating a session title: the `title` agent model wins first, then `small_model`, then automatic/current-model fallback. In v2, users who need a specific title model should configure the `title` agent directly rather than use a separate top-level model setting. + ## Group 8: Agents And Permissions Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices. From b9c9fcbfec97aa3fe015533cd75fdd9cdf9954e3 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 28 May 2026 00:40:37 -0400 Subject: [PATCH 05/19] feat(core): add runtime and sharing config schema --- packages/core/src/config.ts | 27 +++++++++++++ packages/core/src/config/attachments.ts | 15 ++++++++ packages/core/src/config/experimental.ts | 4 +- packages/core/src/config/formatter.ts | 12 ++++++ packages/core/src/config/lsp.ts | 18 +++++++++ packages/core/src/config/plugin.ts | 2 +- packages/core/src/config/reference.ts | 4 +- packages/core/src/config/tool-output.ts | 9 +++++ packages/core/src/config/watcher.ts | 2 +- packages/core/test/config/config.test.ts | 22 +++++++++++ specs/v2/config.md | 49 ++++++++++++++++++++---- 11 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/config/attachments.ts create mode 100644 packages/core/src/config/formatter.ts create mode 100644 packages/core/src/config/lsp.ts create mode 100644 packages/core/src/config/tool-output.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 77c8a98e27..4631b89758 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -8,10 +8,14 @@ import { Global } from "./global" import { Location } from "./location" import { Policy } from "./policy" import { AbsolutePath } from "./schema" +import { ConfigAttachments } from "./config/attachments" import { ConfigExperimental } from "./config/experimental" +import { ConfigFormatter } from "./config/formatter" +import { ConfigLSP } from "./config/lsp" 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("Config.Info")({ @@ -27,12 +31,35 @@ export class Info extends Schema.Class("Config.Info")({ 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", + }), 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", + }), skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ description: "Additional paths or URLs to discover skills from", }), diff --git a/packages/core/src/config/attachments.ts b/packages/core/src/config/attachments.ts new file mode 100644 index 0000000000..f14775ff3f --- /dev/null +++ b/packages/core/src/config/attachments.ts @@ -0,0 +1,15 @@ +export * as ConfigAttachments from "./attachments" + +import { Schema } from "effect" +import { PositiveInt } from "../schema" + +export class Image extends Schema.Class("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("ConfigV2.Attachments")({ + image: Image.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/experimental.ts b/packages/core/src/config/experimental.ts index 377ed26ade..12a02635db 100644 --- a/packages/core/src/config/experimental.ts +++ b/packages/core/src/config/experimental.ts @@ -8,11 +8,11 @@ import { Policy as PolicyV2 } from "../policy" // 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("Config.Experimental.Policy")({ +export class Policy extends Schema.Class("ConfigV2.Experimental.Policy")({ ...PolicyV2.Info.fields, action: PolicyAction, }) {} -export class Experimental extends Schema.Class("Config.Experimental")({ +export class Experimental extends Schema.Class("ConfigV2.Experimental")({ policies: Policy.pipe(Schema.Array, Schema.optional), }) {} diff --git a/packages/core/src/config/formatter.ts b/packages/core/src/config/formatter.ts new file mode 100644 index 0000000000..e1f90302d1 --- /dev/null +++ b/packages/core/src/config/formatter.ts @@ -0,0 +1,12 @@ +export * as ConfigFormatter from "./formatter" + +import { Schema } from "effect" + +export class Entry extends Schema.Class("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)]) diff --git a/packages/core/src/config/lsp.ts b/packages/core/src/config/lsp.ts new file mode 100644 index 0000000000..651597befd --- /dev/null +++ b/packages/core/src/config/lsp.ts @@ -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("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)]) diff --git a/packages/core/src/config/plugin.ts b/packages/core/src/config/plugin.ts index 4268a7f79d..e5fd6661ff 100644 --- a/packages/core/src/config/plugin.ts +++ b/packages/core/src/config/plugin.ts @@ -2,7 +2,7 @@ export * as ConfigPlugin from "./plugin" import { Schema } from "effect" -export class Entry extends Schema.Class("Config.Plugin.Entry")({ +export class Entry extends Schema.Class("ConfigV2.Plugin.Entry")({ package: Schema.String, options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }) {} diff --git a/packages/core/src/config/reference.ts b/packages/core/src/config/reference.ts index 55878bfab0..dc9042e6f7 100644 --- a/packages/core/src/config/reference.ts +++ b/packages/core/src/config/reference.ts @@ -2,12 +2,12 @@ export * as ConfigReference from "./reference" import { Schema } from "effect" -export class Git extends Schema.Class("Config.Reference.Git")({ +export class Git extends Schema.Class("ConfigV2.Reference.Git")({ repository: Schema.String, branch: Schema.String.pipe(Schema.optional), }) {} -export class Local extends Schema.Class("Config.Reference.Local")({ +export class Local extends Schema.Class("ConfigV2.Reference.Local")({ path: Schema.String, }) {} diff --git a/packages/core/src/config/tool-output.ts b/packages/core/src/config/tool-output.ts new file mode 100644 index 0000000000..53e4d4d088 --- /dev/null +++ b/packages/core/src/config/tool-output.ts @@ -0,0 +1,9 @@ +export * as ConfigToolOutput from "./tool-output" + +import { Schema } from "effect" +import { PositiveInt } from "../schema" + +export class Info extends Schema.Class("ConfigV2.ToolOutput")({ + max_lines: PositiveInt.pipe(Schema.optional), + max_bytes: PositiveInt.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts index be5c91a9bf..2df6c876bf 100644 --- a/packages/core/src/config/watcher.ts +++ b/packages/core/src/config/watcher.ts @@ -2,6 +2,6 @@ export * as ConfigWatcher from "./watcher" import { Schema } from "effect" -export class Info extends Schema.Class("Config.Watcher")({ +export class Info extends Schema.Class("ConfigV2.Watcher")({ ignore: Schema.String.pipe(Schema.Array, Schema.optional), }) {} diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index a6f6489e17..aac60acb1e 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -168,8 +168,15 @@ describe("Config", () => { shell: "/bin/bash", model: "anthropic/claude", autoupdate: "notify", + share: "disabled", + enterprise: { url: "https://share.example.com" }, + username: "test-user", 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 }, skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"], instructions: ["CONTRIBUTING.md", ".cursor/rules/*.md", "https://example.com/shared-rules.md"], references: { @@ -193,8 +200,23 @@ describe("Config", () => { 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.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.skills).toEqual([ "./skills", "~/shared-skills", diff --git a/specs/v2/config.md b/specs/v2/config.md index 196c3fc4b3..32a6f996be 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -110,10 +110,31 @@ Settings controlling local file observation, snapshots, language tooling, and to | ------------- | --------------------------------------- | ------- | ----- | | `watcher` | Ignore patterns for filesystem watching | keep | Keep `{ ignore?: string[] }`; this configures the filesystem watcher subsystem. | | `snapshot` | Enable filesystem snapshot tracking | redesign | Rename to plural `snapshots`; controls creation of snapshots used for undo and revert behavior. | -| `formatter` | Configure formatters | pending | | -| `lsp` | Configure language servers | pending | | -| `attachment` | Configure attachment/image processing | pending | | -| `tool_output` | Configure tool output truncation limits | pending | | +| `formatter` | Configure formatters | keep | Keep singular `boolean \| Record` shape; it configures built-in enablement and named formatter overrides. | +| `lsp` | Configure language servers | keep | Keep singular `boolean \| Record` shape; custom servers need commands and file extensions. | +| `attachment` | Configure attachment/image processing | redesign | Rename to plural `attachments`; retain `{ image?: { auto_resize?, max_width?, max_height?, max_base64_bytes? } }` for input normalization limits. | +| `tool_output` | Configure tool output truncation limits | keep | Keep `{ max_lines?, max_bytes? }`; both positive thresholds apply to saved-preview truncation behavior. | + +`formatter` and `lsp` configure one project tooling subsystem each, so their singular names remain appropriate. `true` enables the built-in registrations, `false` disables them, and a keyed object enables built-ins while applying named overrides or custom registrations. Custom language servers must declare `extensions` so runtime file attachment is deterministic; validation of known built-in server IDs belongs with the eventual v2 LSP integration rather than the aggregate core config schema. + +Rename legacy `attachment` to `attachments` in v2. This setting controls processing for the attachment domain and may expand beyond image handling, while singular `attachment` is already used as a model capability flag indicating whether one model accepts attachments. + +```jsonc +{ + "formatter": { + "prettier": { "disabled": true }, + "project": { "command": ["./scripts/format", "$FILE"], "extensions": [".foo"] }, + }, + "lsp": { + "typescript": { "disabled": true }, + "project": { "command": ["project-language-server", "--stdio"], "extensions": [".foo"] }, + }, + "attachments": { + "image": { "auto_resize": true, "max_width": 2000, "max_height": 2000 }, + }, + "tool_output": { "max_lines": 2000, "max_bytes": 51200 }, +} +``` ## Group 6: Sharing And Identity @@ -121,10 +142,22 @@ Settings affecting sharing behavior or user/account identity rather than model e | Field | Current Purpose | Status | Notes | | ------------ | ----------------------------------------------- | ------- | ------------------------------- | -| `share` | Session sharing behavior | pending | | -| `autoshare` | Legacy automatic sharing flag | pending | Deprecated in favor of `share`. | -| `enterprise` | Enterprise URL configuration | pending | | -| `username` | Display username in conversations and telemetry | pending | | +| `share` | Session sharing behavior | keep | Keep `"manual" \| "auto" \| "disabled"`; it controls manual sharing permission and automatic sharing of new sessions. | +| `autoshare` | Legacy automatic sharing flag | remove | Do not port deprecated alias; use `share: "auto"`. | +| `enterprise` | Enterprise URL configuration | keep | Keep `{ url?: string }`; currently selects the legacy sharing service endpoint when no organization account is active. | +| `username` | Display username in conversations and telemetry | keep | Keep string identity override; runtime may otherwise resolve an operating-system username. | + +Retain `share` as the single session-sharing setting. `"manual"` permits explicit sharing, `"auto"` shares newly created top-level sessions, and `"disabled"` prevents sharing. Legacy `autoshare: true` is only an alias for `share: "auto"`, so v2 does not expose it. + +Retain `enterprise.url` for legacy enterprise share hosting selection and `username` as a user-facing identity override. These remain separate from server authentication credentials; `username` identifies the user in conversation and telemetry behavior rather than HTTP basic-auth configuration. + +```jsonc +{ + "share": "disabled", + "enterprise": { "url": "https://share.example.com" }, + "username": "developer", +} +``` ## Group 7: Providers And Model Selection From d9b4f7b84d22c448a42628ac95ca13caf8d93c55 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 28 May 2026 01:43:23 -0400 Subject: [PATCH 06/19] feat(core): add agents and permissions config schema --- packages/core/src/config.ts | 8 +++ packages/core/src/config/agent.ts | 25 ++++++++ packages/core/src/config/provider.ts | 4 +- packages/core/test/config/config.test.ts | 44 +++++++++++++++ packages/core/test/config/provider.test.ts | 2 +- specs/v2/config.md | 66 ++++++++++++++++++++-- 6 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/config/agent.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 4631b89758..d8c0e93009 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -6,8 +6,10 @@ 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 { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" @@ -42,6 +44,12 @@ export class Info extends Schema.Class("Config.Info")({ 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", }), diff --git a/packages/core/src/config/agent.ts b/packages/core/src/config/agent.ts new file mode 100644 index 0000000000..0606ac36c6 --- /dev/null +++ b/packages/core/src/config/agent.ts @@ -0,0 +1,25 @@ +export * as ConfigAgent from "./agent" + +import { Schema } from "effect" +import { PermissionV2 } from "../permission" +import { ProviderV2 } 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("ConfigV2.Agent")({ + model: Schema.String.pipe(Schema.optional), + variant: Schema.String.pipe(Schema.optional), + options: ProviderV2.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), +}) {} diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index d4c6d0848e..4e0c85799f 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -22,7 +22,7 @@ class Model extends Schema.Class("ConfigV2.Model")({ ...ProviderV2.Options.fields, }).pipe(Schema.Array, Schema.optional), cost: ModelV2.Cost.pipe(Schema.Array).pipe(Schema.optional), - enabled: Schema.Boolean.pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), limit: Schema.Struct({ context: Schema.Int, input: Schema.Int.pipe(Schema.optional), @@ -110,7 +110,7 @@ export const Plugin = PluginV2.define({ cache: { ...cost.cache }, })) } - if (config.enabled !== undefined) model.enabled = config.enabled + if (config.disabled !== undefined) model.enabled = !config.disabled if (config.limit !== undefined) model.limit = { ...config.limit } }) } diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index aac60acb1e..4dc86bd73e 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -171,6 +171,29 @@ describe("Config", () => { 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" }, + body: {}, + aisdk: { provider: {}, 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"] } }, @@ -203,6 +226,27 @@ describe("Config", () => { 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" }, + body: {}, + aisdk: { provider: {}, 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({ diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index ed1f18b7d7..b95261e050 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -45,7 +45,7 @@ describe("ConfigProvider.Plugin", () => { chat: { name: "First", capabilities: { tools: true, input: ["text"], output: ["text"] }, - enabled: false, + disabled: true, limit: { context: 100, output: 50 }, options: options({ first: "first", shared: "first" }, "retained"), variants: [ diff --git a/specs/v2/config.md b/specs/v2/config.md index 32a6f996be..49d5987b1d 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -208,11 +208,67 @@ Agent behavior and tool-access policy. Review together because agent configurati | Field | Current Purpose | Status | Notes | | --------------- | --------------------------------------------------- | ------- | ------------------------------------------- | -| `default_agent` | Choose default primary agent | pending | | -| `mode` | Legacy agent configuration alias | pending | Deprecated in favor of `agent`. | -| `agent` | Configure primary, subagent, and specialized agents | pending | | -| `permission` | Tool permission rules | pending | | -| `tools` | Legacy tool enable/disable map | pending | Converted to permissions by current loader. | +| `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. | +| `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. | +| `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. | +| `permission` | Tool permission rules | redesign | Rename to plural `permissions`; replace legacy map shorthand with an ordered array of `{ permission, pattern, action }` rules. | +| `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. | + +Do not port `default_agent` ahead of the v2 agent design. The legacy runtime uses it to choose a visible, non-subagent fallback instead of `build`, but exposing that selection as an isolated top-level field would pre-commit v2 to the legacy agent model before agents and their policy surface are defined together. + +Do not port `mode`. The legacy loader already merges this deprecated alias into `agent`, and v2 should expose only one authoring surface for agent definitions. + +Rename legacy `agent` to `agents` because the setting is a collection keyed by agent name. It should continue to support overriding built-in agents such as `build`, `plan`, and `title`, as well as declaring named custom agents. The nested entry schema remains open until agent-local `permission` and deprecated `tools` behavior are decided. + +Keep nested `agents..mode` with values `"primary"`, `"subagent"`, or `"all"`. This identifies an agent's runtime role and is separate from the removed top-level legacy `mode` alias, which was an alternate container for agent definitions. + +For named configurable entries across v2, use `disabled?: boolean` consistently when an entry should remain configured but inactive. Agent definitions should therefore redesign legacy `disable` as `disabled`; this matches formatters, language servers, future MCP server definitions, and configured model overrides. Runtime catalog state may still track active availability as `enabled`; that is not user-authored config. + +Keep separate `model` and `variant` fields on agent definitions. A model reference uses `provider/model-id`, but model IDs may themselves contain slash-delimited segments, such as `openrouter/openai/gpt-5`; appending a variant to that string would be ambiguous. + +Keep `color` on agent definitions. Agents are user-visible selectable entities, so a user-authored display color is appropriate metadata for the agent rather than an unrelated application presentation setting. Retain hex colors and named theme colors supported by the existing configuration. + +Keep agent-local `options` provisionally using the same structured provider options shape available on configured providers and models: headers, body, and AI SDK provider/request overrides. Its long-term ownership remains open for team review because reusable provider-specific presets can instead be modeled as variants. Do not retain dedicated agent `temperature` or `top_p` fields. + +Retain `description`, `hidden`, and `steps`; they define an agent's discoverability, visibility, and iteration budget rather than model request parameters. Rename legacy agent `prompt` to `system`, making clear that it supplies persistent system-level agent content without colliding with top-level ambient `instructions`. Remove deprecated `maxSteps` in favor of `steps`. + +```jsonc +{ + "agents": { + "reviewer": { + "model": "openrouter/openai/gpt-5", + "variant": "high", + "options": { + "headers": { "x-agent": "reviewer" }, + "body": {}, + "aisdk": { "provider": {}, "request": { "reasoningEffort": "high" } }, + }, + "description": "Review changes for correctness", + "system": "Find regressions and missing tests.", + "mode": "subagent", + "color": "warning", + "steps": 12, + "disabled": false, + "permissions": [ + { "permission": "edit", "pattern": "*", "action": "deny" }, + ], + }, + }, +} +``` + +Do not port `tools`, either as a top-level setting or as an agent-entry alias. The legacy loader already converts tool booleans into permission rules, including collapsing write-adjacent tool names into `edit`; v2 should avoid carrying that lossy compatibility input forward. + +Rename legacy `permission` to `permissions` and expose the normalized ordered ruleset already modeled by `PermissionV2.Ruleset`. Rules retain the interactive `"ask"` action in addition to `"allow"` and `"deny"`; this is distinct from `experimental.policies`, whose provider enforcement currently needs only allow/deny decisions. The same `permissions` ruleset shape should be used inside future `agents` entries. + +```jsonc +{ + "permissions": [ + { "permission": "bash", "pattern": "*", "action": "ask" }, + { "permission": "bash", "pattern": "git status", "action": "allow" }, + ], +} +``` ## Group 9: Integrations From 864f54cc46735d9e10c3b2681c1a59337850a8cc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 28 May 2026 10:27:01 -0400 Subject: [PATCH 07/19] feat(core): add mcp and compaction config schema --- packages/core/src/config.ts | 8 +++ packages/core/src/config/compaction.ts | 16 ++++++ packages/core/src/config/mcp.ts | 36 +++++++++++++ packages/core/test/config/config.test.ts | 50 ++++++++++++++++++ specs/v2/config.md | 66 ++++++++++++++++++++---- 5 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/config/compaction.ts create mode 100644 packages/core/src/config/mcp.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index d8c0e93009..c31348950e 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -11,9 +11,11 @@ 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" @@ -68,6 +70,12 @@ export class Info extends Schema.Class("Config.Info")({ 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", }), diff --git a/packages/core/src/config/compaction.ts b/packages/core/src/config/compaction.ts new file mode 100644 index 0000000000..eef67ee26a --- /dev/null +++ b/packages/core/src/config/compaction.ts @@ -0,0 +1,16 @@ +export * as ConfigCompaction from "./compaction" + +import { Schema } from "effect" +import { NonNegativeInt } from "../schema" + +export class Keep extends Schema.Class("ConfigV2.Compaction.Keep")({ + turns: NonNegativeInt.pipe(Schema.optional), + tokens: NonNegativeInt.pipe(Schema.optional), +}) {} + +export class Info extends Schema.Class("ConfigV2.Compaction")({ + auto: Schema.Boolean.pipe(Schema.optional), + prune: Schema.Boolean.pipe(Schema.optional), + keep: Keep.pipe(Schema.optional), + buffer: NonNegativeInt.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts new file mode 100644 index 0000000000..fce853815b --- /dev/null +++ b/packages/core/src/config/mcp.ts @@ -0,0 +1,36 @@ +export * as ConfigMCP from "./mcp" + +import { Schema } from "effect" +import { PositiveInt } from "../schema" + +export class Local extends Schema.Class("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("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("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("ConfigV2.MCP")({ + timeout: PositiveInt.pipe(Schema.optional), + servers: Schema.Record(Schema.String, Server).pipe(Schema.optional), +}) {} diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 4dc86bd73e..2bf3784b52 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -200,6 +200,31 @@ describe("Config", () => { 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: { @@ -261,6 +286,31 @@ describe("Config", () => { 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", diff --git a/specs/v2/config.md b/specs/v2/config.md index 49d5987b1d..269dd46dfe 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -276,7 +276,39 @@ External protocol and server integration configuration. | Field | Current Purpose | Status | Notes | | ----- | ------------------------------------- | ------- | ----- | -| `mcp` | MCP server definitions and enablement | pending | | +| `mcp` | MCP server definitions and enablement | redesign | Keep opencode's explicit local/remote server entry format, nested under `mcp.servers`; use `disabled` for inactive entries and move timeout here. | + +Keep the opencode MCP server entry format instead of adopting the common `mcpServers` copy/paste shape. Local servers remain explicit `type: "local"` entries with command arrays and `environment`; remote servers remain explicit `type: "remote"` entries with `url`, `headers`, and optional `oauth`. Nest the server map under `mcp.servers` so protocol-wide settings such as default timeout can live under the same subsystem. + +```jsonc +{ + "mcp": { + "timeout": 5000, + "servers": { + "github": { + "type": "local", + "command": ["npx", "-y", "@github/github-mcp-server"], + "environment": { "GITHUB_TOKEN": "{env:GITHUB_TOKEN}" }, + "disabled": false, + "timeout": 10000, + }, + "docs": { + "type": "remote", + "url": "https://docs.example.com/mcp", + "headers": { "Authorization": "Bearer {env:DOCS_TOKEN}" }, + "oauth": { + "client_id": "{env:MCP_CLIENT_ID}", + "client_secret": "{env:MCP_CLIENT_SECRET}", + "scope": "read write", + "callback_port": 19876, + "redirect_uri": "http://127.0.0.1:19876/mcp/oauth/callback", + }, + "disabled": false, + }, + }, + }, +} +``` ## Group 10: Conversation Lifecycle @@ -284,7 +316,23 @@ Behavior affecting long-running conversations and context management. | Field | Current Purpose | Status | Notes | | ------------ | ----------------------------------------------------------- | ------- | ----- | -| `compaction` | Automatic compaction, pruning, and context reserve settings | pending | | +| `compaction` | Automatic compaction, pruning, and context reserve settings | redesign | Group retained verbatim history under `keep` and rename context headroom to `buffer`. | + +Retain the compaction capability but redesign the less clear limits. `keep.turns` is the maximum number of recent user turns to preserve verbatim after compaction, and `keep.tokens` is the token budget for those retained turns. `buffer` is the token headroom reserved so automatic compaction triggers before the input window is exhausted. + +```jsonc +{ + "compaction": { + "auto": true, + "prune": true, + "keep": { + "turns": 2, + "tokens": 2000, + }, + "buffer": 10000, + }, +} +``` ## Group 11: Deprecated And Experimental Settings @@ -292,13 +340,13 @@ Fields that should not be ported by inertia; each needs an explicit justificatio | Field | Current Purpose | Status | Notes | | ------------------------------------ | --------------------------------------- | ------- | ------------------------------------------------------------------- | -| `layout` | Legacy layout selection | pending | Deprecated; current description says stretch layout is always used. | -| `experimental.disable_paste_summary` | Disable pasted-content summary behavior | pending | | -| `experimental.batch_tool` | Enable batch tool | pending | | -| `experimental.openTelemetry` | Enable AI SDK telemetry spans | pending | | -| `experimental.primary_tools` | Restrict tools to primary agents | pending | | -| `experimental.continue_loop_on_deny` | Continue loop after denied tool call | pending | | -| `experimental.mcp_timeout` | MCP request timeout | pending | May belong with MCP rather than experiments. | +| `layout` | Legacy layout selection | remove | Do not port deprecated option; stretch layout is always used. | +| `experimental.disable_paste_summary` | Disable pasted-content summary behavior | remove | Do not port; pasted-input presentation behavior belongs to the client/UI surface. | +| `experimental.batch_tool` | Enable batch tool | remove | Do not port; batch tool is no longer a supported feature. | +| `experimental.openTelemetry` | Enable AI SDK telemetry spans | remove | Do not port; observability is process-level and should use standard OpenTelemetry environment or declarative configuration. | +| `experimental.primary_tools` | Restrict tools to primary agents | remove | Do not port obsolete gating; agent tool access is configured through permissions. | +| `experimental.continue_loop_on_deny` | Continue loop after denied tool call | remove | Do not port legacy denied-tool loop behavior. | +| `experimental.mcp_timeout` | MCP request timeout | redesign | Move to `mcp.timeout` for the default and `mcp.servers..timeout` for per-server overrides. | ## Review Order From a747013bf65e0ec1bb9fe16177ddcb6623618a28 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 28 May 2026 11:14:53 -0400 Subject: [PATCH 08/19] refactor(core): refine provider config schema --- packages/core/src/config/agent.ts | 4 +- packages/core/src/config/plugin/provider.ts | 95 ++++++++++++++ packages/core/src/config/provider.ts | 135 ++++++-------------- packages/core/src/plugin/boot.ts | 4 +- packages/core/test/config/config.test.ts | 6 +- packages/core/test/config/provider.test.ts | 28 ++-- specs/v2/config.md | 29 +++++ 7 files changed, 179 insertions(+), 122 deletions(-) create mode 100644 packages/core/src/config/plugin/provider.ts diff --git a/packages/core/src/config/agent.ts b/packages/core/src/config/agent.ts index 0606ac36c6..40d2bc94b5 100644 --- a/packages/core/src/config/agent.ts +++ b/packages/core/src/config/agent.ts @@ -2,7 +2,7 @@ export * as ConfigAgent from "./agent" import { Schema } from "effect" import { PermissionV2 } from "../permission" -import { ProviderV2 } from "../provider" +import { ConfigProvider } from "./provider" import { PositiveInt } from "../schema" export const Color = Schema.Union([ @@ -13,7 +13,7 @@ export const Color = Schema.Union([ export class Info extends Schema.Class("ConfigV2.Agent")({ model: Schema.String.pipe(Schema.optional), variant: Schema.String.pipe(Schema.optional), - options: ProviderV2.Options.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), diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts new file mode 100644 index 0000000000..4c50b8096f --- /dev/null +++ b/packages/core/src/config/plugin/provider.ts @@ -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 load = yield* catalog.loader() + const files = yield* config.get() + + yield* load((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 } + }) + } + } + } + }) + }), +}) diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index 4e0c85799f..fbb0e1c3ef 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -1,121 +1,62 @@ export * as ConfigProvider from "./provider" -import { Effect, Schema } from "effect" -import { Catalog } from "../catalog" -import { Config } from "../config" +import { Schema } from "effect" import { ProviderV2 } from "../provider" import { ModelV2 } from "../model" -import { PluginV2 } from "../plugin" + +export class Options extends Schema.Class("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("ConfigV2.Model.Cost.Cache")({ + read: Schema.Finite.pipe(Schema.optional), + write: Schema.Finite.pipe(Schema.optional), +}) {} + +class Cost extends Schema.Class("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("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("ConfigV2.Model")({ - apiID: ModelV2.ID.pipe(Schema.optional), + 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({ - ...ProviderV2.Options.fields, + ...Options.fields, variant: Schema.String.pipe(Schema.optional), }).pipe(Schema.optional), variants: Schema.Struct({ id: ModelV2.VariantID, - ...ProviderV2.Options.fields, + ...Options.fields, }).pipe(Schema.Array, Schema.optional), - cost: ModelV2.Cost.pipe(Schema.Array).pipe(Schema.optional), + cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), - limit: Schema.Struct({ - context: Schema.Int, - input: Schema.Int.pipe(Schema.optional), - output: Schema.Int, - }).pipe(Schema.optional), + limit: Limit.pipe(Schema.optional), }) {} export class Info extends Schema.Class("ConfigV2.Provider")({ name: Schema.String.pipe(Schema.optional), + env: Schema.String.pipe(Schema.Array, Schema.optional), endpoint: ProviderV2.Endpoint.pipe(Schema.optional), - options: ProviderV2.Options.pipe(Schema.optional), + options: Options.pipe(Schema.optional), models: Schema.Record(Schema.String, Model).pipe(Schema.optional), }) {} - -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 load = yield* catalog.loader() - const files = yield* config.get() - - yield* load((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 - 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.apiID !== undefined) model.apiID = config.apiID - 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 = config.cost.map((cost) => ({ - tier: cost.tier && { ...cost.tier }, - input: cost.input, - output: cost.output, - cache: { ...cost.cache }, - })) - } - if (config.disabled !== undefined) model.enabled = !config.disabled - if (config.limit !== undefined) model.limit = { ...config.limit } - }) - } - } - } - }) - }), -}) diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index c8c8b4061a..aac7011be4 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -4,11 +4,11 @@ import { Context, Deferred, Effect, Layer } from "effect" import { AccountV2 } from "../account" import { Catalog } from "../catalog" import { Config } from "../config" -import { ConfigProvider } from "../config/provider" 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" @@ -58,7 +58,7 @@ export const layer = Layer.effect( yield* add(item) } yield* add(ModelsDevPlugin) - yield* add(ConfigProvider.Plugin) + yield* add(ConfigProviderPlugin.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 2bf3784b52..53c4eb22ad 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -181,8 +181,7 @@ describe("Config", () => { variant: "high", options: { headers: { "x-agent": "reviewer" }, - body: {}, - aisdk: { provider: {}, request: { reasoningEffort: "high" } }, + aisdk: { request: { reasoningEffort: "high" } }, }, description: "Review changes for correctness", system: "Find regressions.", @@ -260,8 +259,7 @@ describe("Config", () => { variant: "high", options: { headers: { "x-agent": "reviewer" }, - body: {}, - aisdk: { provider: {}, request: { reasoningEffort: "high" } }, + aisdk: { request: { reasoningEffort: "high" } }, }, description: "Review changes for correctness", system: "Find regressions.", diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index b95261e050..5fa4874701 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -2,7 +2,7 @@ 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 { ConfigProvider } from "@opencode-ai/core/config/provider" +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" @@ -11,18 +11,13 @@ import { it } from "../plugin/provider-helper" function options(headers: Record, variant?: string) { return { headers, - body: {}, - aisdk: { - provider: {}, - request: {}, - }, variant, } } const decode = Schema.decodeUnknownSync(Config.Info) -describe("ConfigProvider.Plugin", () => { +describe("ConfigProviderPlugin.Plugin", () => { it.effect("loads configured providers and applies later model overrides", () => Effect.gen(function* () { const catalog = yield* Catalog.Service @@ -39,6 +34,7 @@ describe("ConfigProvider.Plugin", () => { providers: { custom: { name: "Configured", + env: ["CUSTOM_API_KEY"], endpoint: { type: "unknown" }, options: options({ first: "first", shared: "first" }), models: { @@ -47,13 +43,12 @@ describe("ConfigProvider.Plugin", () => { 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" }, - body: {}, - aisdk: { provider: {}, request: {} }, }, ], }, @@ -71,21 +66,18 @@ describe("ConfigProvider.Plugin", () => { options: options({ last: "last", shared: "last" }), models: { chat: { - apiID: "api-chat", + api_id: "api-chat", name: "Last", + limit: { output: 75 }, options: options({ last: "last", shared: "last" }), variants: [ { id: "fast", headers: { last: "last", shared: "last" }, - body: {}, - aisdk: { provider: {}, request: {} }, }, { id: "slow", headers: { slow: "slow" }, - body: {}, - aisdk: { provider: {}, request: {} }, }, ], }, @@ -106,8 +98,8 @@ describe("ConfigProvider.Plugin", () => { }) yield* plugin.add({ - ...ConfigProvider.Plugin, - effect: ConfigProvider.Plugin.effect.pipe( + ...ConfigProviderPlugin.Plugin, + effect: ConfigProviderPlugin.Plugin.effect.pipe( Effect.provideService(Config.Service, config), Effect.provideService(Catalog.Service, catalog), ), @@ -116,6 +108,7 @@ describe("ConfigProvider.Plugin", () => { 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" }) @@ -123,7 +116,8 @@ describe("ConfigProvider.Plugin", () => { 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: 50 }) + 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([ diff --git a/specs/v2/config.md b/specs/v2/config.md index 269dd46dfe..55caf2e668 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -202,6 +202,35 @@ Keep `model` as the default model fallback. It is application-wide behavior used Do not port `small_model`. In the current runtime it is only consulted while generating a session title: the `title` agent model wins first, then `small_model`, then automatic/current-model fallback. In v2, users who need a specific title model should configure the `title` agent directly rather than use a separate top-level model setting. +Provider, model, variant, and provisional agent `options` are authored as partial patches rather than fully materialized runtime option records. Users should be able to set only the override they need, such as a header or an AI SDK request option; catalog state supplies empty defaults and merges patches in configuration order. + +Keep provider `env` as an authored list of recognized credential environment variable names. Built-in catalog providers already carry this metadata for automatic environment-backed availability, and configured providers may need to declare the same source. For a configured provider this is additive metadata, not a requirement that one of the variables exists: the provider may instead be usable through configured options, a stored account, or an endpoint that needs no credential. + +Within configured models, rename legacy upstream model identifier `id` to `api_id` rather than exposing camelCase runtime `apiID`. Model `limit` is an authored patch, so an override may change only `context`, `input`, or `output`. Model `cost` accepts one simple pricing object or an array of tiered pricing entries; omitted cache prices default to zero. + +Do not port legacy provider model `reasoning`, `temperature`, or `interleaved` flags as first-class config fields; provider/request behavior belongs in structured `options` or model variants. Do not port `release_date`, `status`, `experimental`, `whitelist`, or `blacklist` in this v2 surface. + +```jsonc +{ + "providers": { + "internal": { + "env": ["INTERNAL_LLM_API_KEY"], + "options": { "headers": { "Authorization": "Bearer {env:API_KEY}" } }, + "models": { + "chat": { + "api_id": "upstream-chat-model", + "limit": { "output": 32768 }, + "cost": { "input": 1.25, "output": 10 }, + "variants": [ + { "id": "high", "aisdk": { "request": { "reasoningEffort": "high" } } }, + ], + }, + }, + }, + }, +} +``` + ## Group 8: Agents And Permissions Agent behavior and tool-access policy. Review together because agent configuration can contain permissions and model choices. From 506a090a2e01e62add0098f92f35afbe7faa8af8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 28 May 2026 23:32:59 -0400 Subject: [PATCH 09/19] refactor(core): add replayable catalog transforms --- packages/core/src/catalog.ts | 238 ++++++------- packages/core/src/config/plugin/provider.ts | 4 +- packages/core/src/plugin.ts | 24 +- packages/core/src/plugin/account.ts | 2 +- packages/core/src/plugin/env.ts | 2 +- packages/core/src/plugin/models-dev.ts | 4 +- .../src/plugin/provider/amazon-bedrock.ts | 2 +- .../core/src/plugin/provider/anthropic.ts | 2 +- packages/core/src/plugin/provider/azure.ts | 4 +- packages/core/src/plugin/provider/cerebras.ts | 2 +- .../plugin/provider/cloudflare-workers-ai.ts | 2 +- .../src/plugin/provider/github-copilot.ts | 2 +- .../core/src/plugin/provider/google-vertex.ts | 4 +- packages/core/src/plugin/provider/kilo.ts | 2 +- .../core/src/plugin/provider/llmgateway.ts | 2 +- packages/core/src/plugin/provider/nvidia.ts | 2 +- packages/core/src/plugin/provider/openai.ts | 2 +- packages/core/src/plugin/provider/opencode.ts | 2 +- .../core/src/plugin/provider/openrouter.ts | 2 +- packages/core/src/plugin/provider/vercel.ts | 2 +- packages/core/src/plugin/provider/zenmux.ts | 2 +- packages/core/src/transform-state.ts | 67 ++++ packages/core/test/account.test.ts | 15 +- packages/core/test/catalog.test.ts | 69 +++- .../plugin/provider-amazon-bedrock.test.ts | 4 +- .../test/plugin/provider-anthropic.test.ts | 8 +- .../provider-azure-cognitive-services.test.ts | 8 +- .../core/test/plugin/provider-azure.test.ts | 20 +- .../test/plugin/provider-cerebras.test.ts | 8 +- .../provider-cloudflare-workers-ai.test.ts | 16 +- .../plugin/provider-github-copilot.test.ts | 8 +- .../core/test/plugin/provider-gitlab.test.ts | 8 +- .../provider-google-vertex-anthropic.test.ts | 8 +- .../plugin/provider-google-vertex.test.ts | 20 +- packages/core/test/plugin/provider-helper.ts | 3 +- .../core/test/plugin/provider-kilo.test.ts | 12 +- .../test/plugin/provider-llmgateway.test.ts | 8 +- .../core/test/plugin/provider-nvidia.test.ts | 12 +- .../core/test/plugin/provider-openai.test.ts | 8 +- .../test/plugin/provider-opencode.test.ts | 36 +- .../test/plugin/provider-openrouter.test.ts | 12 +- .../core/test/plugin/provider-vercel.test.ts | 12 +- .../core/test/plugin/provider-zenmux.test.ts | 16 +- specs/v2/catalog-config-plugin-lifecycle.md | 326 ++++++++++++++++++ 44 files changed, 700 insertions(+), 312 deletions(-) create mode 100644 packages/core/src/transform-state.ts create mode 100644 specs/v2/catalog-config-plugin-lifecycle.md diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index b12db3ff9e..aafb4c12fb 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,19 +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 { TransformState } from "./transform-state" export type ProviderRecord = { provider: ProviderV2.Info models: Map } +export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID } + export class ProviderNotFoundError extends Schema.TaggedErrorClass()( "CatalogV2.ProviderNotFound", { @@ -37,24 +40,31 @@ export const Event = { }), } -export type Context = { - data: readonly ProviderRecord[] - updateProvider: (providerID: ProviderV2.ID, fn: (provider: Draft) => void) => void - updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft) => void) => void +type State = { + providers: Map + defaultModel?: DefaultModel +} + +export type Editor = { provider: { + list: () => readonly ProviderRecord[] + get: (providerID: ProviderV2.ID) => ProviderRecord | undefined update: (providerID: ProviderV2.ID, fn: (provider: Draft) => 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) => 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 - export interface Interface { - readonly loader: () => Effect.Effect + readonly transform: () => Effect.Effect, never, Scope.Scope> readonly provider: { readonly get: (providerID: ProviderV2.ID) => Effect.Effect readonly all: () => Effect.Effect @@ -68,30 +78,26 @@ export interface Interface { readonly all: () => Effect.Effect readonly available: () => Effect.Effect readonly default: () => Effect.Effect> - readonly setDefault: ( - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - ) => Effect.Effect readonly small: (providerID: ProviderV2.ID) => Effect.Effect> } } export class Service extends Context.Service()("@opencode/v2/Catalog") {} +enableMapSet() + export const layer = Layer.effect( Service, Effect.gen(function* () { yield* Location.Service - let records = HashMap.empty() - 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 state = transformed.get() + const provider = state.providers.get(model.providerID)!.provider const endpoint = model.endpoint.type === "unknown" ? provider.endpoint @@ -124,9 +130,10 @@ 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 state = transformed.get() + const match = state.providers.get(providerID) + if (!match) return yield* new ProviderNotFoundError({ providerID }) + return match } const normalizeEndpoint = (item: Draft | Draft) => { @@ -135,122 +142,79 @@ export const layer = Layer.effect( delete item.options.aisdk.provider.baseURL } - const clone = (input: HashMap.HashMap) => - HashMap.fromIterable( - HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const), - ) - - const context = (draft: { - records: HashMap.HashMap - 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(), - } - 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 transformed = TransformState.create({ + initial: () => ({ providers: new Map() }), + editor: (draft) => { + const data = globalThis.Array.from(draft.providers.values()) as ProviderRecord[] + const result: Editor = { + provider: { + list: () => data, + 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(), + }) + draft.providers.set(providerID, current) + data.push(current) + } + fn(current.provider) + normalizeEndpoint(current.provider) + }, + remove: (providerID) => { + draft.providers.delete(providerID) + const index = data.findIndex((item) => item.provider.id === providerID) + if (index !== -1) data.splice(index, 1) + }, }, - 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 applyPolicy = Effect.fn("CatalogV2.applyPolicy")(function* (draft: { - records: HashMap.HashMap - data: ProviderRecord[] - }) { - const ctx = context(draft) - for (const record of [...draft.data]) { - if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { - ctx.provider.remove(record.provider.id) } - } + return result + }, + rebuild: (catalog) => plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid), + finalize: Effect.fn("CatalogV2.applyPolicy")(function* (catalog) { + 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(), data: [] as ProviderRecord[] } - for (const loader of loaders) loader.update(context(draft)) - yield* plugin.trigger("catalog.transform", context(draft), {}) - yield* applyPolicy(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), {}) - yield* applyPolicy(draft) - records = draft.records - }), + yield* events.subscribe(PluginV2.Event.Added).pipe( + Stream.runForEach((event) => + transformed.update((catalog) => + plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}).pipe(Effect.asVoid), + ), ), 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: transformed.transform, provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { @@ -259,11 +223,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 globalThis.Array.from(transformed.get().providers.values()).map((record) => record.provider) }), available: Effect.fn("CatalogV2.provider.available")(function* () { - return globalThis.Array.from(HashMap.values(records)) + return globalThis.Array.from(transformed.get().providers.values()) .map((record) => record.provider) .filter((provider) => provider.enabled) }), @@ -279,8 +243,7 @@ export const layer = Layer.effect( all: Effect.fn("CatalogV2.model.all")(function* () { return pipe( - records, - HashMap.toValues, + globalThis.Array.from(transformed.get().providers.values()), Array.flatMap((record) => globalThis.Array.from(record.models.values())), Array.map(resolve), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), @@ -289,12 +252,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 = transformed.get().providers.get(model.providerID) return record?.provider.enabled !== false && model.enabled }) }), default: Effect.fn("CatalogV2.model.default")(function* () { + const defaultModel = transformed.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 @@ -307,13 +271,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 = transformed.get().providers.get(providerID) if (!record) return Option.none() if (providerID === ProviderV2.ID.opencode) { @@ -366,7 +325,4 @@ export const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ -export const defaultLayer = layer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide(PluginV2.defaultLayer), -) +export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer)) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 4c50b8096f..fca2e53302 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -12,10 +12,10 @@ export const Plugin = PluginV2.define({ effect: Effect.gen(function* () { const catalog = yield* Catalog.Service const config = yield* Config.Service - const load = yield* catalog.loader() + const transform = yield* catalog.transform() const files = yield* config.get() - yield* load((catalog) => { + yield* transform((catalog) => { for (const file of files) { for (const [id, item] of Object.entries(file.info.providers ?? {})) { const providerID = ProviderV2.ID.make(id) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index e78dbc4059..df8a40c1fa 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -2,16 +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 { 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": { @@ -71,7 +81,6 @@ export interface Interface { effect: Effect.Effect }) => Effect.Effect readonly remove: (id: ID) => Effect.Effect - readonly added: () => Stream.Stream readonly triggerFor: ( id: ID, name: Name, @@ -95,9 +104,7 @@ export const layer = Layer.effect( hooks: HookFunctions scope: Scope.Closeable }[] = [] - const added = yield* PubSub.unbounded() - - yield* Effect.addFinalizer(() => PubSub.shutdown(added)) + const events = yield* EventV2.Service const svc = Service.of({ add: Effect.fn("Plugin.add")(function* (input) { @@ -113,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) }), @@ -163,7 +169,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) // opencode // sdcok diff --git a/packages/core/src/plugin/account.ts b/packages/core/src/plugin/account.ts index d4d00c3ab6..4e77701f31 100644 --- a/packages/core/src/plugin/account.ts +++ b/packages/core/src/plugin/account.ts @@ -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) => { diff --git a/packages/core/src/plugin/env.ts b/packages/core/src/plugin/env.ts index 3d716fe6f3..35e6981a40 100644 --- a/packages/core/src/plugin/env.ts +++ b/packages/core/src/plugin/env.ts @@ -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) => { diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index bde162d729..4733833fdb 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -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) => { diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 44a5d44e2b..e7452ac2e9 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -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) => { diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 99fbf56ab3..026da36349 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -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) => { diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index ea5c34d7c0..bea98cf211 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -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 diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 12da385920..b18884cb6e 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -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) => { diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index d10f8e79e4..32cfb059f4 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -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 diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index dc984cdef9..20b1ad6d6c 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -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, diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 3da7caf4e7..ef36a702bf 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -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 = diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 17436d9f96..098e5576c4 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -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 diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index de1872935c..8a971f0a0d 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -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 diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 0c1301c842..f9c2a0420b 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -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 diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index d76d7417f1..2d33fbcbbe 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -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 diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 411cbe091e..64d20f8bd4 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -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 || diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 317f48158a..dd3e16070b 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -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) => { diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index fa368d10d7..1da00989b3 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -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) => { diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 3b0fcff165..01c3bd8adb 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -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 diff --git a/packages/core/src/transform-state.ts b/packages/core/src/transform-state.ts new file mode 100644 index 0000000000..7852eee076 --- /dev/null +++ b/packages/core/src/transform-state.ts @@ -0,0 +1,67 @@ +export * as TransformState from "./transform-state" + +import { Effect, Scope, Semaphore } from "effect" +import { createDraft, finishDraft, type Draft, type Objectish } from "immer" + +export type Transform = (editor: Editor) => void +export type MakeEditor = (draft: Draft) => Editor +export type SetTransform = (transform: Transform) => Effect.Effect + +export interface Options { + readonly initial: () => State + readonly editor: MakeEditor + /** Applies service-specific work during a full rebuild. */ + readonly rebuild?: (editor: Editor) => Effect.Effect + /** Applies invariants to every committed edit, including incremental edits. */ + readonly finalize?: (editor: Editor) => Effect.Effect +} + +export interface Interface { + readonly get: () => State + readonly transform: () => Effect.Effect, never, Scope.Scope> + readonly update: (update: (editor: Editor) => Effect.Effect) => Effect.Effect +} + +export function create(options: Options): Interface { + let state = options.initial() + let transforms: { update: Transform }[] = [] + const semaphore = Semaphore.makeUnsafe(1) + + const commit = Effect.fn("TransformState.commit")(function* (draft: Draft) { + const api = options.editor(draft) + if (options.finalize) yield* options.finalize(api) + state = finishDraft(draft) as State + }) + + const rebuild = Effect.fn("TransformState.rebuild")(function* () { + const draft = createDraft(options.initial()) + const api = options.editor(draft) + for (const transform of transforms) transform.update(api) + if (options.rebuild) yield* options.rebuild(api) + yield* commit(draft) + }, semaphore.withPermit) + + return { + get: () => state, + transform: Effect.fn("TransformState.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) { + transform.update = update + yield* rebuild() + }) + }), + update: Effect.fn("TransformState.update")(function* (update) { + const draft = createDraft(state) + yield* update(options.editor(draft)) + yield* commit(draft) + }, semaphore.withPermit), + } +} diff --git a/packages/core/test/account.test.ts b/packages/core/test/account.test.ts index cf60740b1e..1e50fd340a 100644 --- a/packages/core/test/account.test.ts +++ b/packages/core/test/account.test.ts @@ -19,12 +19,11 @@ const it = testEffect(PluginV2.defaultLayer) function context( records: { provider: ProviderV2.Info; models: Map }[], 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()), - setDefault: () => Effect.die("unexpected model.setDefault"), small: () => Effect.succeed(Option.none()), }, }) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index fe651e0d78..9736e2a739 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -29,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", @@ -55,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", @@ -84,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", @@ -111,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) @@ -126,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" @@ -142,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" }), @@ -171,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" @@ -201,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: {} } }) @@ -219,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"] @@ -250,10 +281,10 @@ describe("CatalogV2", () => { const catalog = yield* Catalog.Service const policy = yield* Policy.Service const providerID = ProviderV2.ID.make("blocked") - const load = yield* catalog.loader() + const transform = yield* catalog.transform() yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })]) - yield* load((catalog) => { + yield* transform((catalog) => { catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("model"), () => {}) }) diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index f2034eb1e3..602e85624b 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -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: { diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index 0a1d5662d3..6cae612fd2 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -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() }), ) diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 8b8baacb7e..a3837a66ad 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -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" }, }) diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index ae4a03140d..18670d6901 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -34,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" } }) @@ -51,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: {} } }, @@ -100,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" } }) @@ -119,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: {} } }, @@ -141,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: {} } }, diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index dd8d34c7df..982b587a71 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -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({}) }), ) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 2495a0646d..e6c6618599 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -54,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" } }), @@ -86,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" } }), @@ -152,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" } }), @@ -173,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" diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 51822c26ac..c07f70597a 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -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"), () => {}) }) diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index e804d15cfc..f9efa294f5 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -184,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", @@ -232,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", diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index 7cb2b24ffd..9d23dfefb3 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -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" diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index 2a9a18875c..ecab998c3c 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -47,8 +47,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", @@ -86,8 +86,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", @@ -136,8 +136,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", @@ -165,8 +165,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", @@ -201,8 +201,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" diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index 2e4c9ab622..a6d25ac187 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -31,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([]), @@ -42,7 +42,6 @@ export const catalogLayer = Layer.succeed( all: () => Effect.succeed([]), available: () => Effect.succeed([]), default: () => Effect.succeed(Option.none()), - setDefault: () => Effect.die("unexpected model.setDefault"), small: () => Effect.succeed(Option.none()), }, }), diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index cafe4c10f6..ac3cc172a2 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -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" }, }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index f34b7b3fcc..980abe2258 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -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" }, }) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 8bd9051bd8..dea0000717 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -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: { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 93e451e018..b65beb3c71 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -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"), () => {}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 9ed2894fa6..4f4dd6abef 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -24,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) }) @@ -45,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) }) @@ -66,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) }) @@ -87,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) }) @@ -108,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] @@ -131,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: {}, @@ -163,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 @@ -186,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) }) @@ -206,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"] diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index a60323b8d3..d19575a9bb 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -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"), () => {}) }) diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index f6d9efd192..ba34281196 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -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({}) }), ) diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 71067a5a1e..c5899627cf 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -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" }, diff --git a/specs/v2/catalog-config-plugin-lifecycle.md b/specs/v2/catalog-config-plugin-lifecycle.md new file mode 100644 index 0000000000..0781283657 --- /dev/null +++ b/specs/v2/catalog-config-plugin-lifecycle.md @@ -0,0 +1,326 @@ +# Catalog / Config / Plugin Lifecycle Options + +We need to choose where provider/model inputs live and how visible catalog state changes after boot. The designs below compare config, models.dev, auth, plugin activation/disablement, config edits, and policy changes under each option. + +## Scenarios + +- Initial load: a location opens, built-in/configured plugins activate, and the first visible catalog is constructed. +- Config: authored provider/model definitions and overrides. +- models.dev: remote provider/model data refreshed on a timer. +- Auth: active credentials enable/configure providers and can later disappear. +- Plugin activation: a plugin starts contributing while the location is open. +- Plugin disablement: a plugin stops contributing and its influence must disappear. +- Config edit: authored configuration changes while the location is open. +- Policy: allowed/denied provider selection changes after providers exist. + +## A. Config Transforms, Service Reload + +`Config` merges its ordered documents and then runs ordered, replayable plugin transforms. Each transform is a callback receiving `Draft` and may mutate any config field. + +```ts +type ConfigTransform = (config: Draft) => void + +const transform = yield * Config.transform() + +yield * + transform((config) => { + config.providers ??= {} + config.providers.acme = { + /* ... */ + } + config.model = "acme/code" + config.permissions = [ + /* ... */ + ] + }) +``` + +Because a transform can mutate any part of config, a transform change cannot safely trigger only `Catalog.reload()` or any other granular subset. Every service derived from config must reload in place from the newly transformed config. + +```ts +const transform = yield* Config.transform() +yield* transform((draft) => mutateAnyConfigField(draft)) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Agent.reload() + → MCP.reload() + → other config-consuming services reload +``` + +### Initial Load + +Configured plugin installation/updates should not block location readiness. Build an initial snapshot from authored config and fast built-ins, then activate slow plugins in the background and coalesce their resulting reload requests. + +```ts +LocationServiceMap.get(ref) + → build location layer + → Config.layer reads authored documents + → merge authored documents + → run currently active Config transforms + → Policy.layer reads transformed Config + → Catalog.layer reads transformed Config + → materialize baseline provider/model catalog + → PluginBoot baseline ready + → Frontend.fetchCatalog() + +PluginBoot background fiber + → install/update plugin packages concurrently + → activate completed plugins + → Config.transform() + → transform(updateConfig) + → ReloadScheduler.request() + → debounce short burst of completed activations + → Reload.all() + → Config.get() + → run newly active Config transforms + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +The initial layer build is not a reload. `Reload.all()` only runs after the live location changes, such as a background plugin becoming active or a config source changing. Debouncing reduces repeated full-service reloads when multiple plugins complete near each other; each batch still reloads every config-consuming service because a config transform may mutate any field. + +### Config + +```ts +config file loaded + → config source/watch trigger records new documents + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### models.dev + +```ts +timer fires + → ModelsDevPlugin.refresh() + → ModelsDev.get() + → transform(applyModelsDevToConfig) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +`Catalog` does not know about `ModelsDev`; the plugin transforms config before catalog reads it. + +### Auth + +```ts +Account.switched(providerID) + → AuthPlugin.refresh(providerID) + → Account.active(providerID) + → transform(applyAuthToConfig) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Plugin Activation + +```ts +Plugin.activate("acme-models") + → Config.transform() + → transform(applyAcmeConfig) + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Plugin Disablement + +```ts +Plugin.disable("company-naming") + → close plugin scope + → Config internally unregisters transform in finalizer + → Reload.all() + → Policy.reload() + → Catalog.reload() + → sonnet.name = "Sonnet" + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Config Edit + +```ts +file watcher sees edit + → config source/watch trigger records updated documents + → Reload.all() + → Policy.reload() + → Catalog.reload() + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Policy + +```ts +policy config changes + → config source/watch trigger records updated documents + → Reload.all() + → Policy.reload() + → Catalog.reload() + → apply updated policy + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +### Tradeoffs + +- A plugin receives `Draft`, can inspect preceding config state, and can mutate arbitrary config fields through a replayable transform. +- Plugin disablement removes its config transform and lets services rematerialize without manual undo. +- models.dev and auth become config transforms rather than catalog dependencies. +- `Config` owns merge/order semantics for fields visible to transforms. +- Granular service reload is not safe because a config transform can mutate anything; every config-consuming service reloads after any transform change. +- `Catalog` depends on provider/model config semantics and is part of that full service reload. +- One reload produces at most one `Catalog.Event.Updated` notification. +- Deferred plugin activation avoids blocking readiness, but plugin completions may cause repeated full-service reload batches during startup. + +## B. Catalog Transforms + +Plugins register replayable catalog transforms. Each transform receives a `Catalog.Editor` whose helper methods mutate a private catalog draft; `Catalog` rematerializes visible records from its active transforms. + +```ts +interface Catalog { + transform(): Effect.Effect< + (update: (catalog: Catalog.Editor) => void) => Effect.Effect, + never, + Scope.Scope + > +} +``` + +```ts +const transform = yield* Catalog.transform() +yield* transform(update) + → replace this transform callback + → apply active transforms in registration order + → apply policy + → commit diff + → Event.publish(Catalog.Event.Updated) + → Frontend.refetchCatalog() +``` + +### Initial Load + +Configured plugin installation/updates should not block location readiness. Build an initial catalog from immediately available sources, then activate slow plugins in the background and coalesce refresh requests. + +```ts +LocationServiceMap.get(ref) + → build location layer + → Catalog.layer creates empty catalog state + → PluginBoot.layer activates immediately available plugins + → ConfigProviderPlugin installs Catalog.transform() + → ModelsDevPlugin installs Catalog.transform() + → AuthPlugin installs Catalog.transform() + → Catalog.layer applies active transforms during boot + → apply policy + → materialize baseline provider/model catalog + → PluginBoot baseline ready + → Frontend.fetchCatalog() + +PluginBoot background fiber + → install/update plugin packages concurrently + → activate completed plugins + → Catalog.transform() + → transform(updateCatalog) + → Catalog internally rebuilds + → Catalog.Event.Updated + → Frontend.refetchCatalog() +``` + +Each completed plugin activation rebuilds catalog when it calls its transform. Debouncing plugin completions would require adding an explicit batch/suspend-rebuild mechanism; it does not arise from the transform interface itself. + +### Config + +```ts +config file loaded + → ConfigProviderAdapter.load() + → transform(applyConfigToCatalog) + → Catalog internally rebuilds +``` + +### models.dev + +```ts +timer fires + → ModelsDevPlugin.refresh() + → ModelsDev.get() + → transform(applyModelsDevToCatalog) + → Catalog internally rebuilds + → commit diff +``` + +### Auth + +```ts +Account.switched(providerID) + → AuthPlugin.refresh() + → transform(applyAuthToCatalog) + → Catalog internally rebuilds + → replay active transforms including current auth + → apply policy + → commit diff +``` + +### Plugin Activation + +```ts +Plugin.activate("acme-models") + → Catalog.transform() + → transform(applyAcmeToCatalog) + → Catalog internally rebuilds + → commit diff +``` + +### Plugin Disablement + +```ts +Plugin.disable("company-naming") + → close plugin scope + → Catalog internally unregisters transform in finalizer + → Catalog internally rebuilds + → sonnet.name = "Sonnet" + → commit diff +``` + +### Config Edit + +```ts +file watcher sees edit + → ConfigProviderAdapter.load() + → transform(applyUpdatedConfigToCatalog) + → Catalog internally rebuilds +``` + +### Policy + +```ts +policy changes + → Catalog rebuild trigger + → replay all active transforms + → apply updated policy last + → commit diff +``` + +### Tradeoffs + +- Disablement, source refresh, and policy re-evaluation are transform replay operations. +- Auth does not need to be represented as config. +- Config remains one catalog source rather than a catalog dependency. +- The API shape matches A, but the mutable draft is catalog state instead of configuration state. +- Catalog needs transform ordering and internal rebuild behavior in addition to reads. +- Recompute ordering, serialization, and diff events must be specified. +- One internal rebuild produces at most one `Catalog.Event.Updated` notification. +- Deferred plugin activation avoids blocking readiness and only rebuilds catalog for catalog transform changes. +- Debouncing those rebuilds needs an additional batching interface or an activation coordinator that installs multiple transforms before exposing updates. From 1372b9dde39bb7ca2979166c28e7d0de5096c78c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 29 May 2026 20:34:05 -0400 Subject: [PATCH 10/19] refactor(core): unify catalog state finalization --- packages/core/src/catalog.ts | 42 ++++++++----------- .../core/src/{transform-state.ts => state.ts} | 30 ++++++------- 2 files changed, 32 insertions(+), 40 deletions(-) rename packages/core/src/{transform-state.ts => state.ts} (64%) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index aafb4c12fb..da3601fdcd 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -8,7 +8,7 @@ import { ProviderV2 } from "./provider" import { Location } from "./location" import { EventV2 } from "./event" import { Policy } from "./policy" -import { TransformState } from "./transform-state" +import { State } from "./state" export type ProviderRecord = { provider: ProviderV2.Info @@ -40,7 +40,7 @@ export const Event = { }), } -type State = { +type Data = { providers: Map defaultModel?: DefaultModel } @@ -64,7 +64,7 @@ export type Editor = { } export interface Interface { - readonly transform: () => Effect.Effect, never, Scope.Scope> + readonly transform: State.Interface["transform"] readonly provider: { readonly get: (providerID: ProviderV2.ID) => Effect.Effect readonly all: () => Effect.Effect @@ -96,8 +96,7 @@ export const layer = Layer.effect( const scope = yield* Scope.Scope const resolve = (model: ModelV2.Info) => { - const state = transformed.get() - const provider = state.providers.get(model.providerID)!.provider + const provider = state.get().providers.get(model.providerID)!.provider const endpoint = model.endpoint.type === "unknown" ? provider.endpoint @@ -130,8 +129,7 @@ export const layer = Layer.effect( } function* getRecord(providerID: ProviderV2.ID) { - const state = transformed.get() - const match = state.providers.get(providerID) + const match = state.get().providers.get(providerID) if (!match) return yield* new ProviderNotFoundError({ providerID }) return match } @@ -142,13 +140,12 @@ export const layer = Layer.effect( delete item.options.aisdk.provider.baseURL } - const transformed = TransformState.create({ + const state = State.create({ initial: () => ({ providers: new Map() }), editor: (draft) => { - const data = globalThis.Array.from(draft.providers.values()) as ProviderRecord[] const result: Editor = { provider: { - list: () => data, + list: () => globalThis.Array.from(draft.providers.values()) as ProviderRecord[], get: (providerID) => draft.providers.get(providerID), update: (providerID, fn) => { let current = draft.providers.get(providerID) @@ -158,15 +155,12 @@ export const layer = Layer.effect( models: new Map(), }) draft.providers.set(providerID, current) - data.push(current) } fn(current.provider) normalizeEndpoint(current.provider) }, remove: (providerID) => { draft.providers.delete(providerID) - const index = data.findIndex((item) => item.provider.id === providerID) - if (index !== -1) data.splice(index, 1) }, }, model: { @@ -194,8 +188,8 @@ export const layer = Layer.effect( } return result }, - rebuild: (catalog) => plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid), - finalize: Effect.fn("CatalogV2.applyPolicy")(function* (catalog) { + 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) @@ -206,15 +200,13 @@ export const layer = Layer.effect( yield* events.subscribe(PluginV2.Event.Added).pipe( Stream.runForEach((event) => - transformed.update((catalog) => - plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}).pipe(Effect.asVoid), - ), + state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"), ), Effect.forkIn(scope, { startImmediately: true }), ) const result: Interface = { - transform: transformed.transform, + transform: state.transform, provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { @@ -223,11 +215,11 @@ export const layer = Layer.effect( }), all: Effect.fn("CatalogV2.provider.all")(function* () { - return globalThis.Array.from(transformed.get().providers.values()).map((record) => record.provider) + return globalThis.Array.from(state.get().providers.values()).map((record) => record.provider) }), available: Effect.fn("CatalogV2.provider.available")(function* () { - return globalThis.Array.from(transformed.get().providers.values()) + return globalThis.Array.from(state.get().providers.values()) .map((record) => record.provider) .filter((provider) => provider.enabled) }), @@ -243,7 +235,7 @@ export const layer = Layer.effect( all: Effect.fn("CatalogV2.model.all")(function* () { return pipe( - globalThis.Array.from(transformed.get().providers.values()), + globalThis.Array.from(state.get().providers.values()), Array.flatMap((record) => globalThis.Array.from(record.models.values())), Array.map(resolve), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), @@ -252,13 +244,13 @@ export const layer = Layer.effect( available: Effect.fn("CatalogV2.model.available")(function* () { return (yield* result.model.all()).filter((model) => { - const record = transformed.get().providers.get(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 = transformed.get().defaultModel + 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 @@ -272,7 +264,7 @@ export const layer = Layer.effect( }), small: Effect.fn("CatalogV2.model.small")(function* (providerID) { - const record = transformed.get().providers.get(providerID) + const record = state.get().providers.get(providerID) if (!record) return Option.none() if (providerID === ProviderV2.ID.opencode) { diff --git a/packages/core/src/transform-state.ts b/packages/core/src/state.ts similarity index 64% rename from packages/core/src/transform-state.ts rename to packages/core/src/state.ts index 7852eee076..1d67acaa92 100644 --- a/packages/core/src/transform-state.ts +++ b/packages/core/src/state.ts @@ -1,25 +1,26 @@ -export * as TransformState from "./transform-state" +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) => void export type MakeEditor = (draft: Draft) => Editor -export type SetTransform = (transform: Transform) => Effect.Effect export interface Options { readonly initial: () => State readonly editor: MakeEditor - /** Applies service-specific work during a full rebuild. */ - readonly rebuild?: (editor: Editor) => Effect.Effect - /** Applies invariants to every committed edit, including incremental edits. */ - readonly finalize?: (editor: Editor) => Effect.Effect + /** Completes every committed edit; reason identifies exceptional update origins. */ + readonly finalize?: (editor: Editor, reason?: string) => Effect.Effect } export interface Interface { readonly get: () => State - readonly transform: () => Effect.Effect, never, Scope.Scope> - readonly update: (update: (editor: Editor) => Effect.Effect) => Effect.Effect + readonly transform: () => Effect.Effect< + (transform: Transform) => Effect.Effect, + never, + Scope.Scope + > + readonly update: (update: (editor: Editor) => Effect.Effect, reason?: string) => Effect.Effect } export function create(options: Options): Interface { @@ -27,23 +28,22 @@ export function create(options: Options }[] = [] const semaphore = Semaphore.makeUnsafe(1) - const commit = Effect.fn("TransformState.commit")(function* (draft: Draft) { + const commit = Effect.fn("State.commit")(function* (draft: Draft, reason?: string) { const api = options.editor(draft) - if (options.finalize) yield* options.finalize(api) + if (options.finalize) yield* options.finalize(api, reason) state = finishDraft(draft) as State }) - const rebuild = Effect.fn("TransformState.rebuild")(function* () { + 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) - if (options.rebuild) yield* options.rebuild(api) yield* commit(draft) }, semaphore.withPermit) return { get: () => state, - transform: Effect.fn("TransformState.transform")(function* () { + transform: Effect.fn("State.transform")(function* () { const transform = { update: (_editor: Editor) => {} } transforms = [...transforms, transform] const scope = yield* Scope.Scope @@ -58,10 +58,10 @@ export function create(options: Options Date: Fri, 29 May 2026 22:20:35 -0400 Subject: [PATCH 11/19] refactor(core): use effect array for catalog maps --- packages/core/src/catalog.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index da3601fdcd..a12de5d476 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -145,7 +145,7 @@ export const layer = Layer.effect( editor: (draft) => { const result: Editor = { provider: { - list: () => globalThis.Array.from(draft.providers.values()) as ProviderRecord[], + list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[], get: (providerID) => draft.providers.get(providerID), update: (providerID, fn) => { let current = draft.providers.get(providerID) @@ -215,11 +215,11 @@ export const layer = Layer.effect( }), all: Effect.fn("CatalogV2.provider.all")(function* () { - return globalThis.Array.from(state.get().providers.values()).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(state.get().providers.values()) + return Array.fromIterable(state.get().providers.values()) .map((record) => record.provider) .filter((provider) => provider.enabled) }), @@ -235,8 +235,8 @@ export const layer = Layer.effect( all: Effect.fn("CatalogV2.model.all")(function* () { return pipe( - globalThis.Array.from(state.get().providers.values()), - 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)), ) @@ -273,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 && From 8e4cbf60879df9396ed70f539f2892ebe5f83572 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 29 May 2026 23:14:56 -0400 Subject: [PATCH 12/19] feat(core): add configurable agent state --- packages/core/src/agent.ts | 104 ++++++++++++ packages/core/src/config.ts | 12 +- packages/core/src/config/plugin/agent.ts | 66 ++++++++ packages/core/src/model.ts | 2 +- packages/core/src/plugin/boot.ts | 8 +- packages/core/test/agent.test.ts | 105 ++++++++++++ packages/core/test/config/agent.test.ts | 186 +++++++++++++++++++++ packages/core/test/config/config.test.ts | 1 - packages/core/test/config/provider.test.ts | 6 +- packages/core/test/model.test.ts | 23 +++ packages/opencode/src/v2/session.ts | 2 +- 11 files changed, 503 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/agent.ts create mode 100644 packages/core/src/config/plugin/agent.ts create mode 100644 packages/core/test/agent.test.ts create mode 100644 packages/core/test/config/agent.test.ts create mode 100644 packages/core/test/model.test.ts diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts new file mode 100644 index 0000000000..55d95ef77c --- /dev/null +++ b/packages/core/src/agent.ts @@ -0,0 +1,104 @@ +export * as AgentV2 from "./agent" + +import { Array, Context, Effect, Layer, Schema } from "effect" +import { castDraft, enableMapSet, type Draft } from "immer" +import { ModelV2 } from "./model" +import { PermissionV2 } from "./permission" +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 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("AgentV2.Info")({ + id: ID, + model: ModelV2.Ref.pipe(Schema.optional), + options: ProviderV2.Options, + system: Schema.String.pipe(Schema.optional), + 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: [], + }) + } +} + +type Data = { + agents: Map +} + +export type Editor = { + list: () => readonly Info[] + get: (id: ID) => Info | undefined + update: (id: ID, fn: (agent: Draft) => void) => void + remove: (id: ID) => void +} + +export interface Interface { + readonly transform: State.Interface["transform"] + readonly update: State.Interface["update"] + readonly get: (id: ID) => Effect.Effect + readonly all: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Agent") {} + +enableMapSet() + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = State.create({ + 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) + }, + }), + }) + + return Service.of({ + transform: state.transform, + update: state.update, + get: Effect.fn("AgentV2.get")(function* (id) { + return state.get().agents.get(id) + }), + all: Effect.fn("AgentV2.all")(function* () { + return Array.fromIterable(state.get().agents.values()) + }), + }) + }), +) + +export const defaultLayer = layer diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c31348950e..88ff2ef91e 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -92,14 +92,16 @@ export class Info extends Schema.Class("Config.Info")({ providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), }) {} -export class FileSource extends Schema.Class("Config.FileSource")({ +export const FileSource = Schema.Struct({ type: Schema.Literal("file"), path: Schema.String, -}) {} +}).annotate({ identifier: "Config.FileSource" }) +export type FileSource = typeof FileSource.Type -export class MemorySource extends Schema.Class("Config.MemorySource")({ +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 @@ -141,7 +143,7 @@ export const layer = Layer.effect( Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }), ) if (!info) return - return new Loaded({ source: new FileSource({ type: "file", path: filepath }), info }) + return new Loaded({ source: { type: "file", path: filepath }, info }) }) const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts new file mode 100644 index 0000000000..be2aca6297 --- /dev/null +++ b/packages/core/src/config/plugin/agent.ts @@ -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() + + 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) ?? [])) + }) + } + }) + }), +}) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 77b8c60ebe..8cf02ddfe0 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -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 diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index aac7011be4..39faa62f8d 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -2,8 +2,10 @@ export * as PluginBoot from "./boot" 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" @@ -16,7 +18,7 @@ import { ProviderPlugins } from "./provider" type Plugin = { id: PluginV2.ID effect: PluginV2.Effect< - Catalog.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service | Config.Service + Catalog.Service | AccountV2.Service | AgentV2.Service | Npm.Service | EventV2.Service | PluginV2.Service | Config.Service > } @@ -32,6 +34,7 @@ export const layer = Layer.effect( 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 @@ -43,6 +46,7 @@ export const layer = Layer.effect( effect: input.effect.pipe( Effect.provideService(Catalog.Service, catalog), 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), @@ -59,6 +63,7 @@ export const layer = Layer.effect( } yield* add(ModelsDevPlugin) yield* add(ConfigProviderPlugin.Plugin) + yield* add(ConfigAgentPlugin.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( @@ -78,6 +83,7 @@ export const defaultLayer = layer.pipe( Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer), Layer.provide(AccountV2.defaultLayer), + Layer.provide(AgentV2.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Npm.defaultLayer), ) diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts new file mode 100644 index 0000000000..2449a91a7c --- /dev/null +++ b/packages/core/test/agent.test.ts @@ -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() + }), + ) +}) diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts new file mode 100644 index 0000000000..0d8562313b --- /dev/null +++ b/packages/core/test/config/agent.test.ts @@ -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() + }), + ) +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 53c4eb22ad..0a39ed4b3e 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -105,7 +105,6 @@ describe("Config", () => { 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).toBeInstanceOf(Config.FileSource) expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe( path.join(tmp.path, "config.json"), ) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 5fa4874701..01b8c9c9aa 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -29,7 +29,7 @@ describe("ConfigProviderPlugin.Plugin", () => { get: () => Effect.succeed([ new Config.Loaded({ - source: new Config.MemorySource({ type: "memory" }), + source: { type: "memory" }, info: decode({ providers: { custom: { @@ -58,7 +58,7 @@ describe("ConfigProviderPlugin.Plugin", () => { }), }), new Config.Loaded({ - source: new Config.MemorySource({ type: "memory" }), + source: { type: "memory" }, info: decode({ providers: { custom: { @@ -87,7 +87,7 @@ describe("ConfigProviderPlugin.Plugin", () => { }), }), new Config.Loaded({ - source: new Config.MemorySource({ type: "memory" }), + source: { type: "memory" }, info: decode({ providers: { custom: { name: "Renamed" }, diff --git a/packages/core/test/model.test.ts b/packages/core/test/model.test.ts new file mode 100644 index 0000000000..fe97acc25a --- /dev/null +++ b/packages/core/test/model.test.ts @@ -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"), + }) + }) +}) diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 5e477cc8a3..551f030ffc 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -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, From 654cbc1502471cd1cfbbfab23d3f5672c9ce63fb Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 29 May 2026 23:22:48 -0400 Subject: [PATCH 13/19] test(opencode): defer provider timeout stalls --- packages/opencode/test/provider/header-timeout.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index 543bb5f30d..c6493d8927 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -31,7 +31,7 @@ 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()) + yield* llm.push(reply().wait(deferredSleep(250)).text("late").stop()) const provider = yield* Provider.Service const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) @@ -63,7 +63,7 @@ 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()) + yield* llm.push(reply().wait(deferredSleep(250)).text("late").stop()) const provider = yield* Provider.Service const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model")) @@ -192,6 +192,12 @@ function providerConfig(url: string, options: Record = {}) { } } +function deferredSleep(ms: number): PromiseLike { + return { + then: (resolve, reject) => Bun.sleep(ms).then(resolve, reject), + } +} + async function delayedHeaderServer(delay: number): Promise<{ server: Server; url: string }> { const server = createServer((_, res) => { setTimeout(() => { From 6e1b390532d6f312307817bbca18d974f4bf7c2e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 29 May 2026 23:36:39 -0400 Subject: [PATCH 14/19] test(opencode): flush headers before stream timeout --- .../test/provider/header-timeout.test.ts | 121 +++++++++--------- 1 file changed, 62 insertions(+), 59 deletions(-) diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index c6493d8927..8860c39c32 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -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(deferredSleep(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(deferredSleep(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", () => @@ -192,12 +187,6 @@ function providerConfig(url: string, options: Record = {}) { } } -function deferredSleep(ms: number): PromiseLike { - return { - then: (resolve, reject) => Bun.sleep(ms).then(resolve, reject), - } -} - async function delayedHeaderServer(delay: number): Promise<{ server: Server; url: string }> { const server = createServer((_, res) => { setTimeout(() => { @@ -211,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((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(self: Effect.Effect, value: Record = defaultAuthContent()) { return Effect.acquireUseRelease( Effect.sync(() => { From 6ad2967db900e04634c59112bc3c988a817281a2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 29 May 2026 23:42:20 -0400 Subject: [PATCH 15/19] refactor(opencode): defer legacy provider policy integration --- packages/opencode/src/provider/provider.ts | 15 +-- .../test/provider/header-timeout.test.ts | 115 ++++++++---------- 2 files changed, 56 insertions(+), 74 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 73d21d6dd1..49c582e0a9 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -29,9 +29,6 @@ import { ModelID, ProviderID } from "./schema" import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderError } from "./error" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { Policy } from "@opencode-ai/core/policy" -import { AbsolutePath } from "@opencode-ai/core/schema" const log = Log.create({ service: "provider" }) const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 @@ -1210,11 +1207,9 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const modelsDevSvc = yield* ModelsDev.Service const runtimeFlags = yield* RuntimeFlags.Service - const locations = yield* LocationServiceMap - const state = yield* InstanceState.make((ctx) => + const state = yield* InstanceState.make(() => Effect.gen(function* () { - const policy = yield* Policy.Service using _ = log.time("state") const bridge = yield* EffectBridge.make() const cfg = yield* config.get() @@ -1485,10 +1480,7 @@ export const layer = Layer.effect( for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderID.make(id) - if ( - !isProviderAllowed(providerID) || - (yield* policy.evaluate("provider.use", providerID, "allow")) === "deny" - ) { + if (!isProviderAllowed(providerID)) { delete providers[providerID] continue } @@ -1545,7 +1537,7 @@ export const layer = Layer.effect( modelLoaders, varsLoaders, } - }).pipe(Effect.provide(locations.get({ directory: AbsolutePath.make(ctx.directory) }))), + }), ) const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers)) @@ -1881,7 +1873,6 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), - Layer.provide(LocationServiceMap.layer), ), ) diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index 8860c39c32..543bb5f30d 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -3,8 +3,9 @@ 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 } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, provideTmpdirServer } 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" @@ -21,66 +22,70 @@ 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", () => - Effect.gen(function* () { - const server = yield* Effect.acquireRelease( - Effect.promise(() => delayedBodyServer(250)), - (server) => Effect.sync(() => server.server.close()), - ) + provideTmpdirServer( + ({ llm }) => + Effect.gen(function* () { + yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop()) - 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" }], - }) + 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: providerConfig(server.url, { headerTimeout: 50 }) }, - ) - }), + 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 }, + }, + }, + } + }, + }, + ), ) it.live("chunkTimeout raises a response stream error when SSE body stalls", () => - Effect.gen(function* () { - const server = yield* Effect.acquireRelease( - Effect.promise(() => delayedBodyServer(250)), - (server) => Effect.sync(() => server.server.close()), - ) + provideTmpdirServer( + ({ llm }) => + Effect.gen(function* () { + yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop()) - 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 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 - } - } catch (error) { - return error + const error = yield* Effect.promise(async () => { + try { + for await (const part of result.fullStream) { + if (part.type === "error") return part.error } - }) - expect(error).toBeInstanceOf(ProviderError.ResponseStreamError) - }), - { config: providerConfig(server.url, { chunkTimeout: 50 }) }, - ) - }), + } catch (error) { + return error + } + }) + expect(error).toBeInstanceOf(ProviderError.ResponseStreamError) + }), + { config: (url) => providerConfig(url, { chunkTimeout: 50 }) }, + ), ) it.live("headerTimeout aborts when response headers do not arrive", () => @@ -200,20 +205,6 @@ 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((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(self: Effect.Effect, value: Record = defaultAuthContent()) { return Effect.acquireUseRelease( Effect.sync(() => { From b9d552f1dfbdbf3e8bcdf067710dfac258801221 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 29 May 2026 23:49:05 -0400 Subject: [PATCH 16/19] test(opencode): remove deferred provider policy coverage --- .../opencode/test/provider/provider.test.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 60ab677b3e..1ea2c82424 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -6,7 +6,6 @@ import { ModelsDev } from "@opencode-ai/core/models-dev" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Global } from "@opencode-ai/core/global" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { disposeAllInstances, provideInstanceEffect, tmpdirScoped, TestInstance } from "../fixture/fixture" import { markPluginDependenciesReady } from "../fixture/plugin" import { Auth } from "@/auth" @@ -64,7 +63,6 @@ const providerLayer = (flags: Partial = {}) => Layer.provide(Plugin.defaultLayer), Layer.provide(ModelsDev.defaultLayer), Layer.provide(RuntimeFlags.layer(flags)), - Layer.provide(LocationServiceMap.layer), ) const list = Provider.use.list() @@ -102,13 +100,6 @@ const alphaProviderConfig = { }, } -const denyAnthropicPolicyConfig = { - provider: {}, - experimental: { - policies: [{ effect: "deny" as const, action: "provider.use" as const, resource: "anthropic" }], - }, -} - it.instance("provider loaded from env variable", () => Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") @@ -140,16 +131,6 @@ it.instance( { config: { disabled_providers: ["anthropic"] } }, ) -it.instance( - "experimental policies deny provider use", - Effect.gen(function* () { - yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") - const providers = yield* list - expect(providers[ProviderID.anthropic]).toBeUndefined() - }), - { config: denyAnthropicPolicyConfig }, -) - it.instance( "enabled_providers restricts to only listed providers", Effect.gen(function* () { From ac0d5b8d7b0357fbdf82cabd8869d77e0926b790 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 29 May 2026 23:55:36 -0400 Subject: [PATCH 17/19] test(opencode): stabilize provider timeout fixtures --- .../test/provider/header-timeout.test.ts | 115 ++++++++++-------- 1 file changed, 62 insertions(+), 53 deletions(-) diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index 543bb5f30d..8860c39c32 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -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((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(self: Effect.Effect, value: Record = defaultAuthContent()) { return Effect.acquireUseRelease( Effect.sync(() => { From b82aa3865bfd48e1f7a7e64efe11f041ca893196 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 30 May 2026 19:40:30 -0400 Subject: [PATCH 18/19] fix(core): provide agent service in location layer --- packages/core/src/location-layer.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 67293f7c5f..7e9c3f1237 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -4,13 +4,16 @@ import { Catalog } from "./catalog" import { PluginBoot } from "./plugin/boot" import { Policy } from "./policy" import { Config } from "./config" +import { AgentV2 } from "./agent" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { - const result = Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer).pipe( - Layer.provideMerge(Policy.defaultLayer), - Layer.provideMerge(Location.defaultLayer(ref)), - ) + const result = Layer.mergeAll( + Catalog.defaultLayer, + PluginBoot.defaultLayer, + Config.defaultLayer, + AgentV2.defaultLayer, + ).pipe(Layer.provideMerge(Policy.defaultLayer), Layer.provideMerge(Location.defaultLayer(ref))) return result }, idleTimeToLive: "60 minutes", From 72cd2f5fa7afa08345f4e69390a94db266919e59 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 30 May 2026 19:59:56 -0400 Subject: [PATCH 19/19] fix(core): isolate location layer instances --- packages/core/src/catalog.ts | 6 ++- packages/core/src/config.ts | 1 + packages/core/src/location-layer.ts | 12 +++--- packages/core/test/location-layer.test.ts | 50 +++++++++++++++++++++++ 4 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 packages/core/test/location-layer.test.ts diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index a12de5d476..22edc13402 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -317,4 +317,8 @@ export const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ -export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(EventV2.defaultLayer), + Layer.provide(PluginV2.defaultLayer), + Layer.provide(Policy.defaultLayer), +) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 88ff2ef91e..25d9f21111 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -199,4 +199,5 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer), + Layer.provide(Policy.defaultLayer), ) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 7e9c3f1237..23655faef5 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -2,18 +2,16 @@ 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" import { AgentV2 } from "./agent" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { - const result = Layer.mergeAll( - Catalog.defaultLayer, - PluginBoot.defaultLayer, - Config.defaultLayer, - AgentV2.defaultLayer, - ).pipe(Layer.provideMerge(Policy.defaultLayer), Layer.provideMerge(Location.defaultLayer(ref))) + const result = Layer.fresh( + Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer, Config.defaultLayer, AgentV2.defaultLayer).pipe( + Layer.provideMerge(Location.defaultLayer(ref)), + ), + ) return result }, idleTimeToLive: "60 minutes", diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts new file mode 100644 index 0000000000..b4efc07459 --- /dev/null +++ b/packages/core/test/location-layer.test.ts @@ -0,0 +1,50 @@ +import fs from "fs/promises" +import path from "path" +import { describe, expect } from "bun:test" +import { Effect } 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 { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(LocationServiceMap.layer) + +describe("LocationServiceMap", () => { + it.live("isolates location state while sharing location policy with catalog", () => + Effect.acquireRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), + ).pipe( + Effect.flatMap(([blocked, allowed]) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile( + path.join(blocked.path, "opencode.json"), + JSON.stringify({ + experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] }, + }), + ), + ) + + const update = (directory: string) => + Effect.gen(function* () { + yield* PluginBoot.Service.use((boot) => boot.wait()) + const catalog = yield* Catalog.Service + const transform = yield* catalog.transform() + yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + return yield* catalog.provider.all() + }).pipe( + Effect.scoped, + Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })), + ) + + expect((yield* update(blocked.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false) + expect((yield* update(allowed.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true) + }), + ), + ), + ) +})