diff --git a/bun.lock b/bun.lock index 6522800a13..cdc7518d8a 100644 --- a/bun.lock +++ b/bun.lock @@ -122,6 +122,7 @@ }, "packages/client": { "name": "@opencode-ai/client", + "version": "1.17.13", "dependencies": { "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", @@ -695,6 +696,7 @@ "version": "1.17.13", "dependencies": { "@ai-sdk/provider": "3.0.8", + "@opencode-ai/client": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", diff --git a/packages/client/package.json b/packages/client/package.json index 1314239d41..5ee201c819 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,16 +1,30 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/client", - "private": true, + "version": "1.17.13", "type": "module", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/anomalyco/opencode.git", + "directory": "packages/client" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], "exports": { "./promise": "./src/promise/index.ts", - "./effect": "./src/effect/index.ts" + "./promise/api": "./src/promise/api.ts", + "./effect": "./src/effect/index.ts", + "./effect/api": "./src/effect/api.ts" }, "scripts": { + "build": "bun run script/build-package.ts", "generate": "bun run script/build.ts", - "check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated", + "check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api", "test": "bun test --timeout 5000", "typecheck": "tsgo --noEmit" }, diff --git a/packages/client/script/build-package.ts b/packages/client/script/build-package.ts new file mode 100644 index 0000000000..323a63ddf9 --- /dev/null +++ b/packages/client/script/build-package.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import { fileURLToPath } from "node:url" + +process.chdir(fileURLToPath(new URL("..", import.meta.url))) + +await $`rm -rf dist` +await $`bun tsc -p tsconfig.build.json` diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 2ba1dbc9c2..6a83d52b99 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -32,8 +32,8 @@ await Effect.runPromise( fileURLToPath(new URL("../src/effect/generated", import.meta.url)), ), write( - emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }), - fileURLToPath(new URL("../../plugin/src/v2/effect/generated", import.meta.url)), + emitEffectShape(effectContract, { module: "../../contract", api: "ClientApi" }), + fileURLToPath(new URL("../src/effect/api", import.meta.url)), ), ], { concurrency: 3, discard: true }, diff --git a/packages/client/script/publish.ts b/packages/client/script/publish.ts new file mode 100644 index 0000000000..8e37674b64 --- /dev/null +++ b/packages/client/script/publish.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env bun + +import { Script } from "@opencode-ai/script" +import { $ } from "bun" +import { rm } from "node:fs/promises" +import { fileURLToPath } from "node:url" + +process.chdir(fileURLToPath(new URL("..", import.meta.url))) + +const originalText = await Bun.file("package.json").text() +const pkg = JSON.parse(originalText) as { + name: string + version: string + exports: Record +} +const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz` + +if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) { + console.log(`already published ${pkg.name}@${pkg.version}`) + process.exit(0) +} + +try { + await $`bun run typecheck` + await $`bun run build` + pkg.exports = Object.fromEntries( + Object.entries(pkg.exports).map(([key, value]) => { + if (typeof value !== "string") return [key, value] + return [ + key, + { + import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"), + types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"), + }, + ] + }), + ) + await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n") + await rm(tarball, { force: true }) + await $`bun pm pack` + await $`npm publish ${tarball} --tag ${Script.channel} --access public` +} finally { + await Bun.write("package.json", originalText) + await rm(tarball, { force: true }) +} diff --git a/packages/client/src/effect/api.ts b/packages/client/src/effect/api.ts new file mode 100644 index 0000000000..e7cb2012f5 --- /dev/null +++ b/packages/client/src/effect/api.ts @@ -0,0 +1,8 @@ +import type { ModelApi, ProviderApi } from "./api/api.js" + +export type * from "./api/api.js" + +export interface CatalogApi { + readonly provider: ProviderApi + readonly model: ModelApi +} diff --git a/packages/plugin/src/v2/effect/generated/.httpapi-codegen.json b/packages/client/src/effect/api/.httpapi-codegen.json similarity index 100% rename from packages/plugin/src/v2/effect/generated/.httpapi-codegen.json rename to packages/client/src/effect/api/.httpapi-codegen.json diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/client/src/effect/api/api.ts similarity index 99% rename from packages/plugin/src/v2/effect/generated/api.ts rename to packages/client/src/effect/api/api.ts index 8e764daa82..3f4c852be1 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1,7 +1,7 @@ // Generated by @opencode-ai/httpapi-codegen. Do not edit. import type { Effect, Stream } from "effect" import type { HttpApiClient } from "effect/unstable/httpapi" -import type { ClientApi } from "@opencode-ai/protocol/client" +import type { ClientApi } from "../../contract" type RawClient = HttpApiClient.ForApi type EffectValue = A extends Effect.Effect ? Success : never diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 23e6d55c74..50b85da627 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -1,6 +1,21 @@ // TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import // Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations. +import type { Effect } from "effect" + export * from "./generated/index" +export type { + AgentApi, + AppApi, + CatalogApi, + CommandApi, + IntegrationApi, + ModelApi, + PluginApi, + ProviderApi, + ReferenceApi, + SessionApi, + SkillApi, +} from "./api.js" export { Service } from "./service.js" export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" @@ -27,3 +42,4 @@ export { SessionMessage } from "@opencode-ai/schema/session-message" export { Skill } from "@opencode-ai/schema/skill" export { Prompt } from "@opencode-ai/schema/prompt" export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" +export type OpenCodeClient = Effect.Success> diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts new file mode 100644 index 0000000000..a080156942 --- /dev/null +++ b/packages/client/src/promise/api.ts @@ -0,0 +1,39 @@ +import type { + AgentApi as EffectAgentApi, + CommandApi as EffectCommandApi, + IntegrationApi as EffectIntegrationApi, + ModelApi as EffectModelApi, + PluginApi as EffectPluginApi, + ProviderApi as EffectProviderApi, + ReferenceApi as EffectReferenceApi, + SessionApi as EffectSessionApi, + SkillApi as EffectSkillApi, +} from "../effect/api/api.js" +import type { Effect, Stream } from "effect" + +type PromisifyOperation = Operation extends ( + ...args: infer Args +) => Effect.Effect + ? (...args: Args) => Promise + : Operation extends (...args: infer Args) => Stream.Stream + ? (...args: Args) => AsyncIterable + : Operation + +type PromisifyApi = { + readonly [Name in keyof Api]: PromisifyOperation +} + +export type AgentApi = PromisifyApi> +export type CommandApi = PromisifyApi> +export type IntegrationApi = PromisifyApi> +export type ModelApi = PromisifyApi> +export type PluginApi = PromisifyApi> +export type ProviderApi = PromisifyApi> +export type ReferenceApi = PromisifyApi> +export type SessionApi = PromisifyApi> +export type SkillApi = PromisifyApi> + +export interface CatalogApi { + readonly provider: ProviderApi + readonly model: ModelApi +} diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index e9e848b160..9203fe5477 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -1,3 +1,15 @@ export * from "./generated/index" +export type { + AgentApi, + CatalogApi, + CommandApi, + IntegrationApi, + ModelApi, + PluginApi, + ProviderApi, + ReferenceApi, + SessionApi, + SkillApi, +} from "./api.js" export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types" export type OpenCodeClient = ReturnType diff --git a/packages/client/test/api.types.ts b/packages/client/test/api.types.ts new file mode 100644 index 0000000000..5b17b23c09 --- /dev/null +++ b/packages/client/test/api.types.ts @@ -0,0 +1,10 @@ +import { Effect } from "effect" +import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect" + +type EffectClient = Effect.Success> + +declare const effectClient: EffectClient + +const effectApi: EffectApi = effectClient + +void effectApi diff --git a/packages/client/tsconfig.build.json b/packages/client/tsconfig.build.json new file mode 100644 index 0000000000..e235ae78cf --- /dev/null +++ b/packages/client/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "declaration": true + }, + "include": ["src"] +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 47fc90bc55..a7ef7a1fa3 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -3,6 +3,8 @@ "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"], + "allowImportingTsExtensions": false, + "allowJs": false, "noUncheckedIndexedAccess": false }, "include": ["src"] diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 48efe75804..9c6c958025 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -38,65 +38,63 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - yield* ctx.agent.transform( - Effect.fn(function* (draft) { - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([entry]) - return Effect.gen(function* () { - const files = yield* discover(fs, entry.path) - return yield* Effect.forEach(files, (file) => - fs.readFileStringSafe(file.filepath).pipe( - Effect.map((content) => content && decode(file, content)), - Effect.catch(() => Effect.succeed(undefined)), - ), - ).pipe( - Effect.map((documents) => - documents.filter((document): document is Config.Document => document !== undefined), - ), - ) - }) - }).pipe(Effect.map((documents) => documents.flat())) - const global = documents.flatMap((document) => document.info.permissions ?? []) - const configuredDefault = Config.latest(documents, "default_agent") - if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) - for (const current of draft.list()) { - draft.update(current.id, (agent) => agent.permissions.push(...global)) - } + const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([entry]) + return Effect.gen(function* () { + const files = yield* discover(fs, entry.path) + return yield* Effect.forEach(files, (file) => + fs.readFileStringSafe(file.filepath).pipe( + Effect.map((content) => content && decode(file, content)), + Effect.catch(() => Effect.succeed(undefined)), + ), + ).pipe( + Effect.map((documents) => + documents.filter((document): document is Config.Document => document !== undefined), + ), + ) + }) + }).pipe(Effect.map((documents) => documents.flat())) + const global = documents.flatMap((document) => document.info.permissions ?? []) + const configuredDefault = Config.latest(documents, "default_agent") + yield* ctx.agent.transform((draft) => { + if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) + for (const current of draft.list()) { + draft.update(current.id, (agent) => agent.permissions.push(...global)) + } - for (const document of documents) { - for (const [id, item] of Object.entries(document.info.agents ?? {})) { - const agentID = AgentV2.ID.make(id) - if (item.disabled) { - draft.remove(agentID) - continue - } - - const exists = draft.get(agentID) !== undefined - draft.update(agentID, (agent) => { - if (!exists) agent.permissions.push(...global) - 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.request !== undefined) { - Object.assign(agent.request.headers, item.request.headers ?? {}) - Object.assign(agent.request.body, item.request.body ?? {}) - } - 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) agent.permissions.push(...item.permissions) - }) + for (const document of documents) { + for (const [id, item] of Object.entries(document.info.agents ?? {})) { + const agentID = AgentV2.ID.make(id) + if (item.disabled) { + draft.remove(agentID) + continue } + + const exists = draft.get(agentID) !== undefined + draft.update(agentID, (agent) => { + if (!exists) agent.permissions.push(...global) + 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.request !== undefined) { + Object.assign(agent.request.headers, item.request.headers ?? {}) + Object.assign(agent.request.body, item.request.body ?? {}) + } + 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) agent.permissions.push(...item.permissions) + }) } - }), - ) + } + }) }), }) diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index f9b31f8e45..bb7a030cbb 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -17,35 +17,33 @@ export const Plugin = define({ effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service - yield* ctx.command.transform( - Effect.fn(function* (draft) { - const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) - return loadDirectory(fs, entry.path).pipe( - Effect.map((commands) => [ - { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, - ]), - ) - }).pipe(Effect.map((documents) => documents.flat())) - for (const document of documents) { - for (const [name, command] of Object.entries(document.commands ?? {})) { - draft.update(name, (item) => { - item.template = command.template - if (command.description !== undefined) item.description = command.description - if (command.agent !== undefined) item.agent = command.agent - if (command.model !== undefined) { - const model = ModelV2.parse(command.model) - item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } - } - if (command.variant !== undefined && item.model !== undefined) { - item.model.variant = ModelV2.VariantID.make(command.variant) - } - if (command.subtask !== undefined) item.subtask = command.subtask - }) - } + const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) + return loadDirectory(fs, entry.path).pipe( + Effect.map((commands) => [ + { commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }, + ]), + ) + }).pipe(Effect.map((documents) => documents.flat())) + yield* ctx.command.transform((draft) => { + for (const document of documents) { + for (const [name, command] of Object.entries(document.commands ?? {})) { + draft.update(name, (item) => { + item.template = command.template + if (command.description !== undefined) item.description = command.description + if (command.agent !== undefined) item.agent = command.agent + if (command.model !== undefined) { + const model = ModelV2.parse(command.model) + item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } + } + if (command.variant !== undefined && item.model !== undefined) { + item.model.variant = ModelV2.VariantID.make(command.variant) + } + if (command.subtask !== undefined) item.subtask = command.subtask + }) } - }), - ) + } + }) }), }) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index d8ec3a58ae..4992233a1f 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -9,108 +9,101 @@ export const Plugin = define({ id: "config-provider", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - yield* ctx.integration.transform( - Effect.fn(function* (integrations) { - const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") - const configuredIntegrations = new Set( - files.flatMap((file) => - Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => - provider.env === undefined ? [] : [id], - ), - ), - ) - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const integrationID = id - if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue - integrations.update(integrationID, (integration) => { - integration.name = item.name ?? integration.name + const entries = yield* config.entries() + const files = entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredIntegrations = new Set( + files.flatMap((file) => + Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])), + ), + ) + yield* ctx.integration.transform((integrations) => { + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const integrationID = id + if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue + integrations.update(integrationID, (integration) => { + integration.name = item.name ?? integration.name + }) + if (item.env !== undefined) { + integrations.method.update({ + integrationID, + method: { type: "env", names: [...item.env] }, }) - if (item.env !== undefined) { - integrations.method.update({ - integrationID, - method: { type: "env", names: [...item.env] }, - }) - } } } - }), - ) + } + }) - yield* ctx.catalog.transform( - Effect.fn(function* (catalog) { - const entries = yield* config.entries() - const files = entries.filter((entry): entry is Config.Document => entry.type === "document") - const configuredDefault = Config.latest(entries, "model") - if (configuredDefault !== undefined) { - const model = ModelV2.parse(configuredDefault) - catalog.model.default.set(model.providerID, model.modelID) - } - for (const file of files) { - for (const [id, item] of Object.entries(file.info.providers ?? {})) { - const providerID = id - catalog.provider.update(providerID, (provider) => { - if (item.name !== undefined) provider.name = item.name - if (item.api !== undefined) provider.api = { ...item.api } - if (item.request !== undefined) { - Object.assign(provider.request.settings, item.request.settings) - Object.assign(provider.request.headers, item.request.headers) - Object.assign(provider.request.body, item.request.body) - } - }) - for (const [id, config] of Object.entries(item.models ?? {})) { - catalog.model.update(providerID, id, (model) => { - if (config.family !== undefined) model.family = config.family - if (config.name !== undefined) model.name = config.name - if (config.api !== undefined) model.api = { ...model.api, ...config.api } - if (config.capabilities !== undefined) { - model.capabilities = { - tools: config.capabilities.tools, - input: [...config.capabilities.input], - output: [...config.capabilities.output], - } - } - if (config.request !== undefined) { - Object.assign(model.request.settings, config.request.settings) - Object.assign(model.request.headers, config.request.headers) - Object.assign(model.request.body, config.request.body) - if (config.request.variant !== undefined) model.request.variant = config.request.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, - settings: {}, - headers: {}, - body: {}, - } - model.variants.push(existing) - } - Object.assign(existing.settings, variant.settings) - Object.assign(existing.headers, variant.headers) - Object.assign(existing.body, variant.body) - } - } - 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 } - }) + const configuredDefault = Config.latest(entries, "model") + yield* ctx.catalog.transform((catalog) => { + if (configuredDefault !== undefined) { + const model = ModelV2.parse(configuredDefault) + catalog.model.default.set(model.providerID, model.modelID) + } + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const providerID = id + catalog.provider.update(providerID, (provider) => { + if (item.name !== undefined) provider.name = item.name + if (item.api !== undefined) provider.api = { ...item.api } + if (item.request !== undefined) { + Object.assign(provider.request.settings, item.request.settings) + Object.assign(provider.request.headers, item.request.headers) + Object.assign(provider.request.body, item.request.body) } + }) + for (const [id, config] of Object.entries(item.models ?? {})) { + catalog.model.update(providerID, id, (model) => { + if (config.family !== undefined) model.family = config.family + if (config.name !== undefined) model.name = config.name + if (config.api !== undefined) model.api = { ...model.api, ...config.api } + if (config.capabilities !== undefined) { + model.capabilities = { + tools: config.capabilities.tools, + input: [...config.capabilities.input], + output: [...config.capabilities.output], + } + } + if (config.request !== undefined) { + Object.assign(model.request.settings, config.request.settings) + Object.assign(model.request.headers, config.request.headers) + Object.assign(model.request.body, config.request.body) + if (config.request.variant !== undefined) model.request.variant = config.request.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, + settings: {}, + headers: {}, + body: {}, + } + model.variants.push(existing) + } + Object.assign(existing.settings, variant.settings) + Object.assign(existing.headers, variant.headers) + Object.assign(existing.body, variant.body) + } + } + 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/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index d5fa53c411..1c599dab7f 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -16,41 +16,35 @@ export const Plugin = define({ const config = yield* Config.Service const location = yield* Location.Service const global = yield* Global.Service - yield* ctx.reference.transform( - Effect.fn(function* (draft) { - const entries = new Map() - for (const doc of (yield* config.entries()).filter( - (entry): entry is Config.Document => entry.type === "document", - )) { - const directory = doc.path ? path.dirname(doc.path) : location.directory - for (const [name, entry] of Object.entries(doc.info.references ?? {})) { - if (!validAlias(name)) continue - const description = typeof entry === "string" ? undefined : entry.description - const hidden = typeof entry === "string" ? undefined : entry.hidden - entries.set( - name, - local(entry) - ? Reference.LocalSource.make({ - type: "local", - path: AbsolutePath.make( - localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), - ), - ...(description === undefined ? {} : { description }), - ...(hidden === undefined ? {} : { hidden }), - }) - : Reference.GitSource.make({ - type: "git", - repository: typeof entry === "string" ? entry : entry.repository, - ...(entry.branch === undefined ? {} : { branch: entry.branch }), - ...(description === undefined ? {} : { description }), - ...(hidden === undefined ? {} : { hidden }), - }), - ) - } - } - for (const [name, source] of entries) draft.add(name, source) - }), - ) + const entries = new Map() + for (const doc of (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")) { + const directory = doc.path ? path.dirname(doc.path) : location.directory + for (const [name, entry] of Object.entries(doc.info.references ?? {})) { + if (!validAlias(name)) continue + const description = typeof entry === "string" ? undefined : entry.description + const hidden = typeof entry === "string" ? undefined : entry.hidden + entries.set( + name, + local(entry) + ? Reference.LocalSource.make({ + type: "local", + path: AbsolutePath.make(localPath(directory, global.home, typeof entry === "string" ? entry : entry.path)), + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), + }) + : Reference.GitSource.make({ + type: "git", + repository: typeof entry === "string" ? entry : entry.repository, + ...(entry.branch === undefined ? {} : { branch: entry.branch }), + ...(description === undefined ? {} : { description }), + ...(hidden === undefined ? {} : { hidden }), + }), + ) + } + } + yield* ctx.reference.transform((draft) => { + for (const [name, source] of entries) draft.add(name, source) + }) }), }) diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 765992a765..c4b7ba95c1 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -15,36 +15,34 @@ export const Plugin = define({ const config = yield* Config.Service const global = yield* Global.Service const location = yield* Location.Service - yield* ctx.skill.transform( - Effect.fn(function* (draft) { - const entries = yield* config.entries() - const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) - const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) - for (const directory of directories) { - draft.source( - SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), - ) - draft.source( - SkillV2.DirectorySource.make({ - type: "directory", - path: AbsolutePath.make(path.join(directory, "skills")), - }), - ) + const entries = yield* config.entries() + const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) + const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) + yield* ctx.skill.transform((draft) => { + for (const directory of directories) { + draft.source( + SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }), + ) + draft.source( + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.join(directory, "skills")), + }), + ) + } + for (const item of items) { + if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { + draft.source(SkillV2.UrlSource.make({ type: "url", url: item })) + continue } - for (const item of items) { - if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { - draft.source(SkillV2.UrlSource.make({ type: "url", url: item })) - continue - } - const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item - draft.source( - SkillV2.DirectorySource.make({ - type: "directory", - path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), - }), - ) - } - }), - ) + const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item + draft.source( + SkillV2.DirectorySource.make({ + type: "directory", + path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), + }), + ) + } + }) }), }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index d07ba6f0fd..bf3420a023 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -52,6 +52,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }) const isCurrentLocation = (ref: Location.Ref) => ref.directory === location.directory && ref.workspaceID === location.workspaceID + const response = (effect: Effect.Effect) => + effect.pipe(Effect.map((data) => ({ location: locationInfo(), data }))) return { options: {}, @@ -63,15 +65,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }, reload: agents.reload, transform: (callback) => - agents.transform((draft) => + agents.transform((draft) => { callback({ list: () => mutable(draft.list()), get: (id) => mutable(draft.get(AgentV2.ID.make(id))), default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)), update: (id, update) => draft.update(AgentV2.ID.make(id), update), remove: (id) => draft.remove(AgentV2.ID.make(id)), - }), - ), + }) + }), }, aisdk: { sdk: (callback) => @@ -102,9 +104,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }), }, catalog: { + provider: { + list: () => response(catalog.provider.available()), + get: (input) => + catalog.provider + .get(ProviderV2.ID.make(input.providerID)) + .pipe( + Effect.flatMap((provider) => + provider === undefined + ? Effect.fail(new Error(`Provider not found: ${input.providerID}`)) + : response(Effect.succeed(provider)), + ), + ), + }, + model: { + list: () => response(catalog.model.available()), + default: () => response(catalog.model.default()), + }, reload: catalog.reload, transform: (callback) => - catalog.transform((draft) => + catalog.transform((draft) => { callback({ provider: { list: () => mutable(draft.provider.list()), @@ -125,14 +144,39 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), }, }, - }), - ), + }) + }), }, command: { + list: () => response(commands.list()), reload: commands.reload, - transform: commands.transform, + transform: (callback) => + commands.transform((draft) => { + callback(draft) + }), }, integration: { + list: () => response(integration.list()), + get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), + connectKey: (input) => + integration.connection.key({ + integrationID: Integration.ID.make(input.integrationID), + key: input.key, + label: input.label, + }), + connectOauth: (input) => + response( + integration.connection.oauth({ + integrationID: Integration.ID.make(input.integrationID), + methodID: Integration.MethodID.make(input.methodID), + inputs: input.inputs, + label: input.label, + }), + ), + attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), + attemptComplete: (input) => + integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), + attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), reload: integration.reload, connection: { active: (id) => integration.connection.active(Integration.ID.make(id)), @@ -142,7 +186,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int ), }, transform: (callback) => - integration.transform((draft) => + integration.transform((draft) => { callback({ list: () => mutable(draft.list()), get: (id) => mutable(draft.get(Integration.ID.make(id))), @@ -219,33 +263,36 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int remove: (id, method) => draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)), }, - }), - ), + }) + }), }, plugin: { + list: () => response(plugin.list()), add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect), remove: (id) => plugin.remove(PluginV2.ID.make(id)), }, reference: { + list: () => response(reference.list()), reload: reference.reload, transform: (callback) => - reference.transform((draft) => + reference.transform((draft) => { callback({ add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)), remove: draft.remove, list: draft.list, - }), - ), + }) + }), }, skill: { + list: () => response(skill.list()), reload: skill.reload, transform: (callback) => - skill.transform((draft) => + skill.transform((draft) => { callback({ source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)), list: draft.list, - }), - ), + }) + }), }, tool: { register: (input) => tools.register(input), diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 2ab445b248..27c176ed67 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -201,64 +201,65 @@ export const ModelsDevPlugin = define({ effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service - yield* ctx.integration.transform( - Effect.fn(function* (integrations) { - const data = yield* modelsDev.get() - for (const item of Object.values(data)) { - if (item.env.length === 0) continue - const integrationID = item.id - integrations.update(integrationID, (integration) => (integration.name = item.name)) - integrations.method.update({ - integrationID, - method: { type: "key" }, - }) - integrations.method.update({ - integrationID, - method: { type: "env", names: [...item.env] }, - }) - } - }), - ) - yield* ctx.catalog.transform( - Effect.fn(function* (catalog) { - const data = yield* modelsDev.get() - for (const item of Object.values(data)) { - const providerID = ProviderV2.ID.make(item.id) - catalog.provider.update(providerID, (provider) => { - provider.name = item.name - provider.api = item.npm - ? { - type: "aisdk", - package: item.npm, - url: item.api, - } - : { - type: "native", - url: item.api, - settings: {}, - } - }) + const loaded = { data: yield* modelsDev.get() } + yield* ctx.integration.transform((integrations) => { + for (const item of Object.values(loaded.data)) { + if (item.env.length === 0) continue + const integrationID = item.id + integrations.update(integrationID, (integration) => (integration.name = item.name)) + integrations.method.update({ + integrationID, + method: { type: "key" }, + }) + integrations.method.update({ + integrationID, + method: { type: "env", names: [...item.env] }, + }) + } + }) + yield* ctx.catalog.transform((catalog) => { + for (const item of Object.values(loaded.data)) { + const providerID = ProviderV2.ID.make(item.id) + catalog.provider.update(providerID, (provider) => { + provider.name = item.name + provider.api = item.npm + ? { + type: "aisdk", + package: item.npm, + url: item.api, + } + : { + type: "native", + url: item.api, + settings: {}, + } + }) - for (const model of Object.values(item.models)) { - const baseCost = cost(model.cost) - const variants = reasoningVariants(item, model) - catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants })) - for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { - catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => - applyModel(draft, model, { - name: modeName(model, mode), - cost: mergeCost(baseCost, options.cost), - request: options.provider, - variants, - }), - ) - } + for (const model of Object.values(item.models)) { + const baseCost = cost(model.cost) + const variants = reasoningVariants(item, model) + catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants })) + for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { + catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => + applyModel(draft, model, { + name: modeName(model, mode), + cost: mergeCost(baseCost, options.cost), + request: options.provider, + variants, + }), + ) } } - }), - ) + } + }) yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( - Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))), + Stream.runForEach(() => + modelsDev.get().pipe( + Effect.tap((data) => Effect.sync(() => (loaded.data = data))), + Effect.andThen(ctx.integration.reload()), + Effect.andThen(ctx.catalog.reload()), + ), + ), Effect.forkScoped({ startImmediately: true }), ) }), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index d0bf82c2a0..956c530176 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -1,12 +1,11 @@ export * as PluginPromise from "./promise" import { define } from "@opencode-ai/plugin/v2/effect" -import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise" +import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" import { Effect, Scope } from "effect" -// The Effect host hands back this registration shape; mirror it structurally so -// we do not have to alias the Effect package's `Registration` against the Promise one. type HostRegistration = { readonly dispose: Effect.Effect } +type Registration = { readonly dispose: () => Promise } /** * Adapts a Promise plugin into an Effect plugin so the existing Effect-only @@ -31,20 +30,23 @@ export function fromPromise(plugin: Plugin) { dispose: () => Effect.runPromiseWith(context)(registration.dispose), })) - const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect) + const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect) const transform = (domain: { - transform: ( - callback: (draft: Draft) => Effect.Effect | void, - ) => Effect.Effect + transform: (callback: (draft: Draft) => void) => Effect.Effect }) => - (callback: (draft: Draft) => Promise | void) => - register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft))))) + (callback: (draft: Draft) => void) => + register( + domain.transform((draft) => { + callback(draft) + }), + ) const context2: PluginContext = { options: host.options, agent: { + list: (input) => run(host.agent.list(input)), transform: transform(host.agent), reload: () => run(host.agent.reload()), }, @@ -55,14 +57,30 @@ export function fromPromise(plugin: Plugin) { register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))), }, catalog: { + provider: { + list: (input) => run(host.catalog.provider.list(input)), + get: (input) => run(host.catalog.provider.get(input)), + }, + model: { + list: (input) => run(host.catalog.model.list(input)), + default: (input) => run(host.catalog.model.default(input)), + }, transform: transform(host.catalog), reload: () => run(host.catalog.reload()), }, command: { + list: (input) => run(host.command.list(input)), transform: transform(host.command), reload: () => run(host.command.reload()), }, integration: { + list: (input) => run(host.integration.list(input)), + get: (input) => run(host.integration.get(input)), + connectKey: (input) => run(host.integration.connectKey(input)), + connectOauth: (input) => run(host.integration.connectOauth(input)), + attemptStatus: (input) => run(host.integration.attemptStatus(input)), + attemptComplete: (input) => run(host.integration.attemptComplete(input)), + attemptCancel: (input) => run(host.integration.attemptCancel(input)), transform: transform(host.integration), reload: () => run(host.integration.reload()), connection: { @@ -71,6 +89,7 @@ export function fromPromise(plugin: Plugin) { }, }, plugin: { + list: (input) => run(host.plugin.list(input)), add: (input) => { const child = fromPromise(input) return run(host.plugin.add(child)) @@ -78,13 +97,22 @@ export function fromPromise(plugin: Plugin) { remove: (id) => run(host.plugin.remove(id)), }, reference: { + list: (input) => run(host.reference.list(input)), transform: transform(host.reference), reload: () => run(host.reference.reload()), }, skill: { + list: (input) => run(host.skill.list(input)), transform: transform(host.skill), reload: () => run(host.skill.reload()), }, + session: { + create: (input) => run(host.session.create(input)), + get: (input) => run(host.session.get(input)), + prompt: (input) => run(host.session.prompt(input)), + command: (input) => run(host.session.command(input)), + interrupt: (input) => run(host.session.interrupt(input)), + }, } yield* Effect.promise(() => Promise.resolve(plugin.setup(context2))) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 0995cf1c17..cbf9defc09 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -62,22 +62,20 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) { export const AmazonBedrockPlugin = define({ id: "amazon-bedrock", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue - evt.provider.update(item.provider.id, (provider) => { - if (provider.api.type !== "aisdk") return - if (typeof provider.request.body.endpoint !== "string") return - // The AI SDK expects a base URL, but users configure Bedrock private/VPC - // endpoints as `endpoint`; move it into the catalog endpoint URL once. - provider.api.url = provider.request.body.endpoint - delete provider.request.body.endpoint - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue + evt.provider.update(item.provider.id, (provider) => { + if (provider.api.type !== "aisdk") return + if (typeof provider.request.body.endpoint !== "string") return + // The AI SDK expects a base URL, but users configure Bedrock private/VPC + // endpoints as `endpoint`; move it into the catalog endpoint URL once. + provider.api.url = provider.request.body.endpoint + delete provider.request.body.endpoint + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index cf883a0687..efbd50b818 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -4,18 +4,16 @@ import { define } from "../internal" export const AnthropicPlugin = define({ id: "anthropic", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/anthropic") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["anthropic-beta"] = - "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/anthropic") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["anthropic-beta"] = + "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 2e1f9d9b48..2e73e620bf 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -13,21 +13,19 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) { export const AzurePlugin = define({ id: "azure", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/azure") continue - const configured = item.provider.request.body.resourceName - const resourceName = - typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME - if (!resourceName) continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.body.resourceName = resourceName - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/azure") continue + const configured = item.provider.request.body.resourceName + const resourceName = + typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME + if (!resourceName) continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.body.resourceName = resourceName + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return @@ -58,20 +56,18 @@ export const AzurePlugin = define({ export const AzureCognitiveServicesPlugin = define({ id: "azure-cognitive-services", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME - if (!resourceName) return - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (!item.provider.id.includes("azure-cognitive-services")) continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME + if (!resourceName) return + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + if (!item.provider.id.includes("azure-cognitive-services")) continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` + }) + } + }) yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 0fd651160f..57029cfba7 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -4,17 +4,15 @@ import { define } from "../internal" export const CerebrasPlugin = define({ id: "cerebras", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/cerebras") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/cerebras") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 1a1c533eb5..762ceb4d96 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -9,18 +9,16 @@ const providerID = ProviderV2.ID.make("cloudflare-workers-ai") export const CloudflareWorkersAIPlugin = define({ id: "cloudflare-workers-ai", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - const item = evt.provider.get(providerID) - if (!item) return - evt.provider.update(item.provider.id, (provider) => { - if (provider.api.type !== "aisdk") return - if (provider.api.url) return - const accountId = resolveAccountId(provider.request.body) - if (accountId) provider.api.url = workersEndpoint(accountId) - }) - }), - ) + yield* ctx.catalog.transform((evt) => { + const item = evt.provider.get(providerID) + if (!item) return + evt.provider.update(item.provider.id, (provider) => { + if (provider.api.type !== "aisdk") return + if (provider.api.url) return + const accountId = resolveAccountId(provider.request.body) + if (accountId) provider.api.url = workersEndpoint(accountId) + }) + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 682579d7a9..d116058493 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -14,17 +14,15 @@ function shouldUseResponses(modelID: string) { export const GithubCopilotPlugin = define({ id: "github-copilot", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - 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, - // so hide it only for Copilot rather than for every provider catalog. - model.enabled = false - }) - }), - ) + yield* ctx.catalog.transform((evt) => { + 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, + // so hide it only for Copilot rather than for every provider catalog. + model.enabled = false + }) + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 4e643c9f51..b87fe61a32 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -57,33 +57,31 @@ function authFetch(fetchWithRuntimeOptions?: unknown) { export const GoogleVertexPlugin = define({ id: "google-vertex", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if ( - item.provider.api.package !== "@ai-sdk/google-vertex" && - !( - item.provider.id === ProviderV2.ID.googleVertex && - item.provider.api.package.includes("@ai-sdk/openai-compatible") - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if ( + item.provider.api.package !== "@ai-sdk/google-vertex" && + !( + item.provider.id === ProviderV2.ID.googleVertex && + item.provider.api.package.includes("@ai-sdk/openai-compatible") ) - continue - const project = resolveProject(item.provider.request.body) - const location = String(resolveLocation(item.provider.request.body)) - evt.provider.update(item.provider.id, (provider) => { - if (project) provider.request.body.project = project - provider.request.body.location = location - if (provider.api.type === "aisdk" && provider.api.url) { - provider.api.url = replaceVertexVars(provider.api.url, project, location) - } - if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) { - provider.request.body.fetch = authFetch(provider.request.body.fetch) - } - }) - } - }), - ) + ) + continue + const project = resolveProject(item.provider.request.body) + const location = String(resolveLocation(item.provider.request.body)) + evt.provider.update(item.provider.id, (provider) => { + if (project) provider.request.body.project = project + provider.request.body.location = location + if (provider.api.type === "aisdk" && provider.api.url) { + provider.api.url = replaceVertexVars(provider.api.url, project, location) + } + if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) { + provider.request.body.fetch = authFetch(provider.request.body.fetch) + } + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { @@ -115,28 +113,26 @@ export const GoogleVertexPlugin = define({ export const GoogleVertexAnthropicPlugin = define({ id: "google-vertex-anthropic", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue - const project = - item.provider.request.body.project ?? - process.env.GOOGLE_CLOUD_PROJECT ?? - process.env.GCP_PROJECT ?? - process.env.GCLOUD_PROJECT - const location = - item.provider.request.body.location ?? - process.env.GOOGLE_CLOUD_LOCATION ?? - process.env.VERTEX_LOCATION ?? - "global" - evt.provider.update(item.provider.id, (provider) => { - if (project) provider.request.body.project = project - provider.request.body.location = location - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue + const project = + item.provider.request.body.project ?? + process.env.GOOGLE_CLOUD_PROJECT ?? + process.env.GCP_PROJECT ?? + process.env.GCLOUD_PROJECT + const location = + item.provider.request.body.location ?? + process.env.GOOGLE_CLOUD_LOCATION ?? + process.env.VERTEX_LOCATION ?? + "global" + evt.provider.update(item.provider.id, (provider) => { + if (project) provider.request.body.project = project + provider.request.body.location = location + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 6ee6670ee5..5d4c0d65d8 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -4,18 +4,16 @@ import { define } from "../internal" export const KiloPlugin = define({ id: "kilo", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.request.headers["X-Title"] = "opencode" + }) + } + }) }), }) diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index eafc5edd6c..005880c6f3 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -6,21 +6,20 @@ export const LLMGatewayPlugin = define({ id: "llmgateway", effect: Effect.fn(function* (ctx) { const integrations = yield* Integration.Service - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.disabled) continue - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue - if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" - provider.request.headers["X-Source"] = "opencode" - }) - } - }), - ) + const configured = new Set((yield* integrations.list()).map((integration) => integration.id)) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.disabled) continue + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue + if (!configured.has(Integration.ID.make(item.provider.id))) continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.request.headers["X-Title"] = "opencode" + provider.request.headers["X-Source"] = "opencode" + }) + } + }) }), }) diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 449599727c..b1b9c9b117 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -4,19 +4,17 @@ import { define } from "../internal" export const NvidiaPlugin = define({ id: "nvidia", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" - provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.request.headers["X-Title"] = "opencode" + provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode" + }) + } + }) }), }) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index e8974195a6..990a82554e 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -177,34 +177,32 @@ export const OpenAIPlugin = define({ draft.method.update(browser) draft.method.update(headless) }) - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai") continue - if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue - evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { - // OpenAIPlugin sends OpenAI models through Responses; this alias is a - // chat-completions-only model, so hide it only from OpenAI's catalog. - model.enabled = false - }) - } - if (!chatgpt) return - const item = evt.provider.get(ProviderV2.ID.openai) - if (!item) return - for (const model of item.models.values()) { - // ChatGPT-plan tokens only authorize codex-eligible models, and the - // subscription covers usage, so hide the rest and zero the cost. - evt.model.update(item.provider.id, model.id, (draft) => { - if (!OpenAICodex.eligible(draft.api.id)) { - draft.enabled = false - return - } - draft.cost = [] - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai") continue + if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // OpenAIPlugin sends OpenAI models through Responses; this alias is a + // chat-completions-only model, so hide it only from OpenAI's catalog. + model.enabled = false + }) + } + if (!chatgpt) return + const item = evt.provider.get(ProviderV2.ID.openai) + if (!item) return + for (const model of item.models.values()) { + // ChatGPT-plan tokens only authorize codex-eligible models, and the + // subscription covers usage, so hide the rest and zero the cost. + evt.model.update(item.provider.id, model.id, (draft) => { + if (!OpenAICodex.eligible(draft.api.id)) { + draft.enabled = false + return + } + draft.cost = [] + }) + } + }) const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 0f295fb095..458ea1cd9b 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -5,26 +5,24 @@ import { define } from "../internal" export const OpenRouterPlugin = define({ id: "openrouter", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" - provider.request.headers["X-Title"] = "opencode" + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" + provider.request.headers["X-Title"] = "opencode" + }) + for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) { + if (!item.models.has(modelID)) continue + evt.model.update(item.provider.id, modelID, (model) => { + // These are OpenRouter-specific OpenAI chat aliases that do not work + // on the generic path. Keep custom providers with matching IDs untouched. + model.enabled = false }) - for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) { - if (!item.models.has(modelID)) continue - evt.model.update(item.provider.id, modelID, (model) => { - // These are OpenRouter-specific OpenAI chat aliases that do not work - // on the generic path. Keep custom providers with matching IDs untouched. - model.enabled = false - }) - } } - }), - ) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 00f5601430..8ff4d14098 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -4,18 +4,16 @@ import { define } from "../internal" export const VercelPlugin = define({ id: "vercel", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/vercel") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["http-referer"] = "https://opencode.ai/" - provider.request.headers["x-title"] = "opencode" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/vercel") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["http-referer"] = "https://opencode.ai/" + provider.request.headers["x-title"] = "opencode" + }) + } + }) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 29adebc0ee..4562d6bd2e 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -4,18 +4,16 @@ import { define } from "../internal" export const ZenmuxPlugin = define({ id: "zenmux", effect: Effect.fn(function* (ctx) { - yield* ctx.catalog.transform( - Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - if (item.provider.api.type !== "aisdk") continue - if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue - if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue - evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/" - provider.request.headers["X-Title"] ??= "opencode" - }) - } - }), - ) + yield* ctx.catalog.transform((evt) => { + for (const item of evt.provider.list()) { + if (item.provider.api.type !== "aisdk") continue + if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue + if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue + evt.provider.update(item.provider.id, (provider) => { + provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/" + provider.request.headers["X-Title"] ??= "opencode" + }) + } + }) }), }) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index 362a8e5da5..6c7f2ecc02 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -53,7 +53,15 @@ Review files`, }) const command = yield* CommandV2.Service - yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe( + yield* ConfigCommandPlugin.Plugin.effect( + host({ + command: { + list: () => Effect.die("unused command.list"), + transform: command.transform, + reload: command.reload, + }, + }), + ).pipe( Effect.provideService( Config.Service, Config.Service.of({ diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 0dc5e3b4d7..28ae7956dc 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -36,7 +36,7 @@ describe("ConfigSkillPlugin.Plugin", () => { yield* ConfigSkillPlugin.Plugin.effect( host({ - skill: { transform, reload: () => Effect.void }, + skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void }, }), ).pipe( Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })), diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index be84975f64..65848dbd72 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -23,7 +23,11 @@ describe("CommandPlugin.Plugin", () => { const command = yield* CommandV2.Service yield* CommandPlugin.Plugin.effect( host({ - command: { transform: command.transform, reload: command.reload }, + command: { + list: () => Effect.die("unused command.list"), + transform: command.transform, + reload: command.reload, + }, }), ).pipe( Effect.provideService( diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index b9fc03b9c9..c5f168f265 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -23,14 +23,30 @@ export function host(overrides: Overrides = {}): PluginContext { language: () => Effect.die("unused aisdk.language"), }, catalog: overrides.catalog ?? { + provider: { + list: () => Effect.die("unused catalog.provider.list"), + get: () => Effect.die("unused catalog.provider.get"), + }, + model: { + list: () => Effect.die("unused catalog.model.list"), + default: () => Effect.die("unused catalog.model.default"), + }, transform: () => Effect.die("unused catalog.transform"), reload: () => Effect.die("unused catalog.reload"), }, command: overrides.command ?? { + list: () => Effect.die("unused command.list"), transform: () => Effect.die("unused command.transform"), reload: () => Effect.die("unused command.reload"), }, integration: overrides.integration ?? { + list: () => Effect.die("unused integration.list"), + get: () => Effect.die("unused integration.get"), + connectKey: () => Effect.die("unused integration.connectKey"), + connectOauth: () => Effect.die("unused integration.connectOauth"), + attemptStatus: () => Effect.die("unused integration.attemptStatus"), + attemptComplete: () => Effect.die("unused integration.attemptComplete"), + attemptCancel: () => Effect.die("unused integration.attemptCancel"), transform: () => Effect.die("unused integration.transform"), reload: () => Effect.die("unused integration.reload"), connection: { @@ -39,14 +55,17 @@ export function host(overrides: Overrides = {}): PluginContext { }, }, plugin: overrides.plugin ?? { + list: () => Effect.die("unused plugin.list"), add: () => Effect.die("unused plugin.add"), remove: () => Effect.die("unused plugin.remove"), }, reference: overrides.reference ?? { + list: () => Effect.die("unused reference.list"), transform: () => Effect.die("unused reference.transform"), reload: () => Effect.die("unused reference.reload"), }, skill: overrides.skill ?? { + list: () => Effect.die("unused skill.list"), transform: () => Effect.die("unused skill.transform"), reload: () => Effect.die("unused skill.reload"), }, @@ -94,6 +113,14 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] { return { + provider: { + list: () => Effect.die("unused catalog.provider.list"), + get: () => Effect.die("unused catalog.provider.get"), + }, + model: { + list: () => Effect.die("unused catalog.model.list"), + default: () => Effect.die("unused catalog.model.default"), + }, reload: catalog.reload, transform: (callback) => catalog.transform((draft) => @@ -158,6 +185,13 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog" export function integrationHost(integration: Integration.Interface): PluginContext["integration"] { return { + list: () => Effect.die("unused integration.list"), + get: () => Effect.die("unused integration.get"), + connectKey: () => Effect.die("unused integration.connectKey"), + connectOauth: () => Effect.die("unused integration.connectOauth"), + attemptStatus: () => Effect.die("unused integration.attemptStatus"), + attemptComplete: () => Effect.die("unused integration.attemptComplete"), + attemptCancel: () => Effect.die("unused integration.attemptCancel"), reload: integration.reload, connection: { active: (id) => integration.connection.active(Integration.ID.make(id)), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 41a6641946..fbe4029c3a 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -11,6 +11,35 @@ import { PluginTestLayer } from "./fixture" const it = testEffect(PluginTestLayer) describe("fromPromise", () => { + it.effect("forwards standard client reads", () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + const seen: string[] = [] + const promisePlugin = define({ + id: "promise-client-reads", + setup: async (ctx) => { + const results = await Promise.all([ + ctx.agent.list(), + ctx.catalog.provider.list(), + ctx.catalog.model.list(), + ctx.command.list(), + ctx.integration.list(), + ctx.plugin.list(), + ctx.reference.list(), + ctx.skill.list(), + ]) + seen.push(...results.map((result) => result.location.directory)) + }, + }) + + yield* PluginPromise.fromPromise(promisePlugin).effect(host) + + expect(seen).toHaveLength(8) + expect(new Set(seen).size).toBe(1) + }), + ) + it.effect("loads a promise plugin and registers a transform hook", () => Effect.gen(function* () { const agents = yield* AgentV2.Service diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 1bc49f3716..1a2ba5009d 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -19,7 +19,15 @@ describe("SkillPlugin.Plugin", () => { it.effect("registers built-in skills", () => Effect.gen(function* () { const skill = yield* SkillV2.Service - yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })).pipe( + yield* SkillPlugin.Plugin.effect( + host({ + skill: { + list: () => Effect.die("unused skill.list"), + transform: skill.transform, + reload: skill.reload, + }, + }), + ).pipe( Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })), Effect.provideService( Location.Service, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index e5f9c43dfc..d97f655253 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -23,6 +23,7 @@ ], "dependencies": { "@ai-sdk/provider": "3.0.8", + "@opencode-ai/client": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts index 3c9e6d4210..ce56cbe777 100644 --- a/packages/plugin/src/v2/effect/agent.ts +++ b/packages/plugin/src/v2/effect/agent.ts @@ -1,6 +1,7 @@ import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" -import type { AgentApi } from "./generated/api.js" -import type { Hooks } from "./registration.js" +import type { AgentApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { TransformHook } from "./registration.js" export interface AgentDraft { list(): readonly AgentV2Info[] @@ -10,9 +11,7 @@ export interface AgentDraft { remove(id: string): void } -export type AgentHooks = Hooks<{ - transform: AgentDraft -}> - -export type AgentPluginApi = AgentHooks -export type AgentDomain = AgentApi & AgentPluginApi +export interface AgentHooks extends AgentApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index 704ef16bf6..4cc5e55227 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -1,5 +1,7 @@ import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types" -import type { Hooks } from "./registration.js" +import type { CatalogApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { TransformHook } from "./registration.js" export interface CatalogProviderRecord { readonly provider: ProviderV2Info @@ -24,6 +26,7 @@ export interface CatalogDraft { } } -export type CatalogHooks = Hooks<{ - transform: CatalogDraft -}> +export interface CatalogHooks extends CatalogApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts index 0afd4bafe0..2faaa91fa9 100644 --- a/packages/plugin/src/v2/effect/command.ts +++ b/packages/plugin/src/v2/effect/command.ts @@ -1,5 +1,7 @@ import type { CommandV2Info } from "@opencode-ai/sdk/v2/types" -import type { Hooks } from "./registration.js" +import type { CommandApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { TransformHook } from "./registration.js" export interface CommandDraft { list(): readonly CommandV2Info[] @@ -8,6 +10,7 @@ export interface CommandDraft { remove(name: string): void } -export type CommandHooks = Hooks<{ - transform: CommandDraft -}> +export interface CommandHooks extends CommandApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts index 3f77fcd48d..db2f4c0ee6 100644 --- a/packages/plugin/src/v2/effect/context.ts +++ b/packages/plugin/src/v2/effect/context.ts @@ -1,5 +1,5 @@ import type { PluginOptions } from "../options.js" -import type { AgentDomain } from "./agent.js" +import type { AgentHooks } from "./agent.js" import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" @@ -7,20 +7,19 @@ import type { IntegrationHooks } from "./integration.js" import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" import type { SkillHooks } from "./skill.js" -import type { Reload } from "./registration.js" import type { ToolDomain } from "./tool.js" -import type { SessionDomain } from "./runtime.js" +import type { SessionHooks } from "./runtime.js" export interface PluginContext { readonly options: PluginOptions - readonly agent: AgentDomain & Reload + readonly agent: AgentHooks readonly aisdk: AISDKHooks - readonly catalog: CatalogHooks & Reload - readonly command: CommandHooks & Reload - readonly integration: IntegrationHooks & Reload + readonly catalog: CatalogHooks + readonly command: CommandHooks + readonly integration: IntegrationHooks readonly plugin: PluginDomain - readonly reference: ReferenceHooks & Reload - readonly skill: SkillHooks & Reload + readonly reference: ReferenceHooks + readonly skill: SkillHooks readonly tool: ToolDomain - readonly session: SessionDomain + readonly session: SessionHooks } diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 295c948766..12b5971fef 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,6 +1,13 @@ export type { PluginContext } from "./context.js" export { define } from "./plugin.js" -export type { Plugin } from "./plugin.js" +export type { Plugin, PluginDomain } from "./plugin.js" +export type { AgentDraft, AgentHooks } from "./agent.js" +export type { AISDKHooks } from "./aisdk.js" +export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" +export type { CommandDraft, CommandHooks } from "./command.js" +export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" +export type { ReferenceDraft, ReferenceHooks } from "./reference.js" +export type { SkillDraft, SkillHooks } from "./skill.js" export * as Tool from "./tool.js" export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js" -export type { SessionDomain } from "./runtime.js" +export type { SessionHooks } from "./runtime.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 64052b1506..1433786634 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -9,8 +9,9 @@ import type { IntegrationOAuthMethod, IntegrationRef, } from "@opencode-ai/sdk/v2/types" +import type { IntegrationApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type IntegrationOAuthAuthorization = { readonly url: string @@ -55,7 +56,9 @@ export interface IntegrationDraft { } } -export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> { +export interface IntegrationHooks extends IntegrationApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect readonly connection: { readonly active: (integrationID: string) => Effect.Effect readonly resolve: (connection: ConnectionInfo) => Effect.Effect diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 54224fa831..77629bdfca 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,3 +1,4 @@ +import type { PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" import type { PluginContext } from "./context.js" @@ -10,7 +11,7 @@ export function define(plugin: Plugin) { return plugin } -export interface PluginDomain { +export interface PluginDomain extends PluginApi { readonly add: (plugin: Plugin) => Effect.Effect readonly remove: (id: string) => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/reference.ts b/packages/plugin/src/v2/effect/reference.ts index c1e2630476..1216d3c0b8 100644 --- a/packages/plugin/src/v2/effect/reference.ts +++ b/packages/plugin/src/v2/effect/reference.ts @@ -1,5 +1,7 @@ import type { ReferenceGitSource, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" -import type { Hooks } from "./registration.js" +import type { ReferenceApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { TransformHook } from "./registration.js" export interface ReferenceDraft { add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void @@ -7,6 +9,7 @@ export interface ReferenceDraft { list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] } -export type ReferenceHooks = Hooks<{ - transform: ReferenceDraft -}> +export interface ReferenceHooks extends ReferenceApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/v2/effect/registration.ts b/packages/plugin/src/v2/effect/registration.ts index dfe5626397..1ddb9e3489 100644 --- a/packages/plugin/src/v2/effect/registration.ts +++ b/packages/plugin/src/v2/effect/registration.ts @@ -4,12 +4,10 @@ export interface Registration { readonly dispose: Effect.Effect } -export interface Reload { - readonly reload: () => Effect.Effect -} - export type Hooks = { readonly [Name in keyof Spec]: ( callback: (input: Spec[Name]) => Effect.Effect | void, ) => Effect.Effect } + +export type TransformHook = (callback: (input: Input) => void) => Effect.Effect diff --git a/packages/plugin/src/v2/effect/runtime.ts b/packages/plugin/src/v2/effect/runtime.ts index ca2074a977..0358330210 100644 --- a/packages/plugin/src/v2/effect/runtime.ts +++ b/packages/plugin/src/v2/effect/runtime.ts @@ -1,3 +1,4 @@ -import type { SessionApi } from "./generated/api.js" +import type { SessionApi } from "@opencode-ai/client/effect/api" -export type SessionDomain = Pick, "create" | "get" | "prompt" | "command" | "interrupt"> +export interface SessionHooks + extends Pick, "create" | "get" | "prompt" | "command" | "interrupt"> {} diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts index cac658a083..b01b81bc3b 100644 --- a/packages/plugin/src/v2/effect/skill.ts +++ b/packages/plugin/src/v2/effect/skill.ts @@ -1,11 +1,14 @@ import type { SkillV2Source } from "@opencode-ai/sdk/v2/types" -import type { Hooks } from "./registration.js" +import type { SkillApi } from "@opencode-ai/client/effect/api" +import type { Effect } from "effect" +import type { TransformHook } from "./registration.js" export interface SkillDraft { source(source: SkillV2Source): void list(): readonly SkillV2Source[] } -export type SkillHooks = Hooks<{ - transform: SkillDraft -}> +export interface SkillHooks extends SkillApi { + readonly transform: TransformHook + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/v2/promise/agent.ts b/packages/plugin/src/v2/promise/agent.ts index bec589146a..d9313838cb 100644 --- a/packages/plugin/src/v2/promise/agent.ts +++ b/packages/plugin/src/v2/promise/agent.ts @@ -1,8 +1,10 @@ +import type { AgentApi } from "@opencode-ai/client/promise/api" import type { AgentDraft } from "../effect/agent.js" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { AgentDraft } -export type AgentHooks = Hooks<{ - transform: AgentDraft -}> +export interface AgentHooks extends AgentApi { + readonly transform: TransformHook + readonly reload: () => Promise +} diff --git a/packages/plugin/src/v2/promise/catalog.ts b/packages/plugin/src/v2/promise/catalog.ts index 70842e94b7..f6b0f649f8 100644 --- a/packages/plugin/src/v2/promise/catalog.ts +++ b/packages/plugin/src/v2/promise/catalog.ts @@ -1,8 +1,10 @@ +import type { CatalogApi } from "@opencode-ai/client/promise/api" import type { CatalogDraft, CatalogProviderRecord } from "../effect/catalog.js" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { CatalogDraft, CatalogProviderRecord } -export type CatalogHooks = Hooks<{ - transform: CatalogDraft -}> +export interface CatalogHooks extends CatalogApi { + readonly transform: TransformHook + readonly reload: () => Promise +} diff --git a/packages/plugin/src/v2/promise/command.ts b/packages/plugin/src/v2/promise/command.ts index cdc5f8268a..d042259c47 100644 --- a/packages/plugin/src/v2/promise/command.ts +++ b/packages/plugin/src/v2/promise/command.ts @@ -1,8 +1,10 @@ +import type { CommandApi } from "@opencode-ai/client/promise/api" import type { CommandDraft } from "../effect/command.js" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { CommandDraft } -export type CommandHooks = Hooks<{ - transform: CommandDraft -}> +export interface CommandHooks extends CommandApi { + readonly transform: TransformHook + readonly reload: () => Promise +} diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts index 9089334ee3..652deee9bb 100644 --- a/packages/plugin/src/v2/promise/context.ts +++ b/packages/plugin/src/v2/promise/context.ts @@ -6,17 +6,18 @@ import type { CommandHooks } from "./command.js" import type { IntegrationHooks } from "./integration.js" import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" +import type { SessionHooks } from "./runtime.js" import type { SkillHooks } from "./skill.js" -import type { Reload } from "./registration.js" export interface PluginContext { readonly options: PluginOptions - readonly agent: AgentHooks & Reload + readonly agent: AgentHooks readonly aisdk: AISDKHooks - readonly catalog: CatalogHooks & Reload - readonly command: CommandHooks & Reload - readonly integration: IntegrationHooks & Reload + readonly catalog: CatalogHooks + readonly command: CommandHooks + readonly integration: IntegrationHooks readonly plugin: PluginDomain - readonly reference: ReferenceHooks & Reload - readonly skill: SkillHooks & Reload + readonly reference: ReferenceHooks + readonly session: SessionHooks + readonly skill: SkillHooks } diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 8287955657..1050463e70 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -2,11 +2,11 @@ export type { PluginContext } from "./context.js" export type { PluginOptions } from "../options.js" export { define } from "./plugin.js" export type { Plugin, PluginDomain } from "./plugin.js" -export type { Registration, Reload } from "./registration.js" export type { AgentDraft, AgentHooks } from "./agent.js" export type { AISDKHooks } from "./aisdk.js" export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" export type { CommandDraft, CommandHooks } from "./command.js" export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" export type { ReferenceDraft, ReferenceHooks } from "./reference.js" +export type { SessionHooks } from "./runtime.js" export type { SkillDraft, SkillHooks } from "./skill.js" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index 6a798a0291..bd133e889e 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -1,10 +1,13 @@ +import type { IntegrationApi } from "@opencode-ai/client/promise/api" import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js" import type { CredentialValue } from "@opencode-ai/sdk/v2/types" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { IntegrationDraft, IntegrationMethodRegistration } -export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> { +export interface IntegrationHooks extends IntegrationApi { + readonly transform: TransformHook + readonly reload: () => Promise readonly connection: { readonly active: (integrationID: string) => Promise readonly resolve: ( diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index ab59fb95fc..afb6c16f0e 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,3 +1,4 @@ +import type { PluginApi } from "@opencode-ai/client/promise/api" import type { PluginContext } from "./context.js" export interface Plugin { @@ -9,7 +10,7 @@ export function define(plugin: Plugin) { return plugin } -export interface PluginDomain { +export interface PluginDomain extends PluginApi { readonly add: (plugin: Plugin) => Promise readonly remove: (id: string) => Promise } diff --git a/packages/plugin/src/v2/promise/reference.ts b/packages/plugin/src/v2/promise/reference.ts index f4b7f8b839..66bd5b4874 100644 --- a/packages/plugin/src/v2/promise/reference.ts +++ b/packages/plugin/src/v2/promise/reference.ts @@ -1,8 +1,10 @@ +import type { ReferenceApi } from "@opencode-ai/client/promise/api" import type { ReferenceDraft } from "../effect/reference.js" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { ReferenceDraft } -export type ReferenceHooks = Hooks<{ - transform: ReferenceDraft -}> +export interface ReferenceHooks extends ReferenceApi { + readonly transform: TransformHook + readonly reload: () => Promise +} diff --git a/packages/plugin/src/v2/promise/registration.ts b/packages/plugin/src/v2/promise/registration.ts index 5e0ae7f480..76be0982b8 100644 --- a/packages/plugin/src/v2/promise/registration.ts +++ b/packages/plugin/src/v2/promise/registration.ts @@ -2,10 +2,8 @@ export interface Registration { readonly dispose: () => Promise } -export interface Reload { - readonly reload: () => Promise -} - export type Hooks = { readonly [Name in keyof Spec]: (callback: (input: Spec[Name]) => Promise | void) => Promise } + +export type TransformHook = (callback: (input: Input) => void) => Promise diff --git a/packages/plugin/src/v2/promise/runtime.ts b/packages/plugin/src/v2/promise/runtime.ts new file mode 100644 index 0000000000..b89b6e8abf --- /dev/null +++ b/packages/plugin/src/v2/promise/runtime.ts @@ -0,0 +1,3 @@ +import type { SessionApi } from "@opencode-ai/client/promise/api" + +export interface SessionHooks extends Pick {} diff --git a/packages/plugin/src/v2/promise/skill.ts b/packages/plugin/src/v2/promise/skill.ts index 2efc35f818..a1e62fc544 100644 --- a/packages/plugin/src/v2/promise/skill.ts +++ b/packages/plugin/src/v2/promise/skill.ts @@ -1,8 +1,10 @@ +import type { SkillApi } from "@opencode-ai/client/promise/api" import type { SkillDraft } from "../effect/skill.js" -import type { Hooks } from "./registration.js" +import type { TransformHook } from "./registration.js" export type { SkillDraft } -export type SkillHooks = Hooks<{ - transform: SkillDraft -}> +export interface SkillHooks extends SkillApi { + readonly transform: TransformHook + readonly reload: () => Promise +}