Rename v2 auth service to account (#28260)

This commit is contained in:
Dax
2026-05-21 00:53:27 +00:00
committed by GitHub
parent bd41dac88f
commit 8643c0721e
67 changed files with 2541 additions and 1379 deletions
+319
View File
@@ -0,0 +1,319 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
import { EventV2 } from "./event"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AccountV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AccountV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AccountV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Info extends Schema.Class<Info>("AccountV2.Info")({
id: ID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AccountV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type Error = FileWriteError
export const Event = {
Added: EventV2.define({
type: "account.added",
schema: {
account: Info,
},
}),
Removed: EventV2.define({
type: "account.removed",
schema: {
account: Info,
},
}),
Switched: EventV2.define({
type: "account.switched",
schema: {
serviceID: ServiceID,
from: Schema.optional(ID),
to: Schema.optional(ID),
},
}),
}
interface Writable {
version: 2
accounts: Record<string, Info>
active: Record<string, ID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Info> = {}
const active: Record<string, ID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const account = ID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Info({
id: account,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = account
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
readonly all: () => Effect.Effect<Info[], Error>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
}) => Effect.Effect<Info | undefined, Error>
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
readonly remove: (id: ID) => Effect.Effect<void, Error>
readonly activate: (id: ID) => Effect.Effect<void, Error>
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const events = yield* EventV2.Service
const file = path.join(global.data, "account.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(
yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
)
const activate = Effect.fn("AccountV2.activate")(function* (id: ID) {
const data = yield* SynchronizedRef.get(state)
const account = data.accounts[id]
if (!account) return
const activated = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const nextAccount = data.accounts[id]
if (!nextAccount) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
yield* write(next)
return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
}),
)
if (activated) yield* events.publish(Event.Switched, activated)
})
const result: Interface = {
get: Effect.fn("AccountV2.get")(function* (id) {
return (yield* SynchronizedRef.get(state)).accounts[id]
}),
all: Effect.fn("AccountV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AccountV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AccountV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AccountV2.add")(function* (input) {
const id = ID.make(Identifier.ascending())
const account = new Info({
id,
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const added = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active: { ...data.active, [account.serviceID]: account.id },
}
yield* write(next)
return [
{
account,
switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
},
next,
] as const
}),
)
yield* events.publish(Event.Added, { account: added.account })
yield* events.publish(Event.Switched, added.switched)
return added.account
}),
update: Effect.fn("AccountV2.update")(function* (id, updates) {
const existing = (yield* SynchronizedRef.get(state)).accounts[id]
if (!existing) return
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
if (!data.accounts[id]) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[id]: new Info({
id,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AccountV2.remove")(function* (id) {
const removed = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
const removed = accounts[id]
if (!removed) return [undefined, data] as const
const wasActive = active[removed.serviceID] === id
delete accounts[id]
const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
if (wasActive) {
if (replacement) active[removed.serviceID] = replacement.id
else delete active[removed.serviceID]
}
const next = { ...data, accounts, active }
yield* write(next)
return [
{
account: removed,
switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
},
next,
] as const
}),
)
if (removed) {
yield* events.publish(Event.Removed, { account: removed.account })
if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
}
}),
activate,
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export * as AccountV2 from "./account"
+147
View File
@@ -0,0 +1,147 @@
export * as AgentV2 from "./agent"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export type ID = typeof ID.Type
export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" })
export type Mode = typeof Mode.Type
export const Info = Schema.Struct({
name: ID,
description: Schema.optional(Schema.String),
mode: Mode,
hidden: Schema.Boolean.pipe(Schema.optional),
color: Schema.String.pipe(Schema.optional),
permission: PermissionV2.Ruleset,
model: ModelV2.Ref.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
options: ProviderV2.Options.pipe(Schema.optional),
steps: Schema.Int.pipe(Schema.optional),
}).annotate({ identifier: "AgentV2.Info" })
export type Info = typeof Info.Type
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("AgentV2.NotFound", {
agent: ID,
}) {}
export class InvalidDefaultError extends Schema.TaggedErrorClass<InvalidDefaultError>()("AgentV2.InvalidDefault", {
agent: ID,
reason: Schema.Literals(["missing", "subagent", "hidden"]),
}) {}
export class NoDefaultError extends Schema.TaggedErrorClass<NoDefaultError>()("AgentV2.NoDefault", {}) {}
export interface Interface {
readonly get: (agent: ID) => Effect.Effect<Info, NotFoundError>
readonly list: () => Effect.Effect<Info[]>
readonly update: (agent: ID, fn: (agent: Draft<Info>) => void) => Effect.Effect<void>
readonly remove: (agent: ID) => Effect.Effect<void>
readonly defaultInfo: () => Effect.Effect<Info, InvalidDefaultError | NoDefaultError>
readonly defaultAgent: () => Effect.Effect<ID, InvalidDefaultError | NoDefaultError>
readonly setDefault: (agent: ID) => Effect.Effect<void, NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
let agents = HashMap.empty<ID, Info>()
let defaultAgent: ID | undefined
const result: Interface = {
get: Effect.fn("AgentV2.get")(function* (agent) {
const match = HashMap.get(agents, agent)
if (!match.valueOrUndefined) return yield* new NotFoundError({ agent })
return match.value
}),
list: Effect.fn("AgentV2.list")(function* () {
return pipe(
HashMap.toValues(agents),
Array.sortWith((agent) => agent.name, Order.String),
)
}),
update: Effect.fnUntraced(function* (agent, fn) {
const next = produce(
HashMap.get(agents, agent).pipe(
Option.getOrElse(
() =>
({
name: agent,
mode: "all",
permission: [],
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
},
}) satisfies Info,
),
),
fn,
)
const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false })
if (updated.cancel) return
agents = HashMap.set(agents, agent, { ...updated.agent, name: agent })
}),
remove: Effect.fn("AgentV2.remove")(function* (agent) {
const existing = Option.getOrUndefined(HashMap.get(agents, agent))
if (!existing) return
if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return
agents = HashMap.remove(agents, agent)
if (defaultAgent === agent) defaultAgent = undefined
}),
defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () {
const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent })
const selected = updated.agent
if (selected) {
const agent = yield* result
.get(selected)
.pipe(
Effect.catchTag("AgentV2.NotFound", () =>
Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })),
),
)
if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" })
if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" })
return agent
}
const visible = pipe(
yield* result.list(),
Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true),
)
if (Option.isSome(visible)) return visible.value
return yield* new NoDefaultError()
}),
defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () {
return (yield* result.defaultInfo()).name
}),
setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) {
yield* result.get(agent)
defaultAgent = agent
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
-264
View File
@@ -1,264 +0,0 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const AccountID = Schema.String.pipe(
Schema.brand("AccountID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type AccountID = typeof AccountID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AuthV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AuthV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AuthV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Account extends Schema.Class<Account>("AuthV2.Account")({
id: AccountID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class AuthFileWriteError extends Schema.TaggedErrorClass<AuthFileWriteError>()("AuthV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type AuthError = AuthFileWriteError
interface Writable {
version: 2
accounts: Record<string, Account>
active: Record<string, AccountID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Account> = {}
const active: Record<string, AccountID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const accountID = AccountID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Account({
id: accountID,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = accountID
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (accountID: AccountID) => Effect.Effect<Account | undefined, AuthError>
readonly all: () => Effect.Effect<Account[], AuthError>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
active?: boolean
}) => Effect.Effect<Account, AuthError>
readonly update: (
accountID: AccountID,
updates: Partial<Pick<Account, "description" | "credential">>,
) => Effect.Effect<void, AuthError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly activate: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly active: (serviceID: ServiceID) => Effect.Effect<Account | undefined, AuthError>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Account[], AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const file = path.join(global.data, "auth-v2.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, AuthError> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(yield* load())
const result: Interface = {
get: Effect.fn("AuthV2.get")(function* (accountID) {
return (yield* SynchronizedRef.get(state)).accounts[accountID]
}),
all: Effect.fn("AuthV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AuthV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AuthV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AuthV2.add")(function* (input) {
return yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = new Account({
id: AccountID.make(Identifier.ascending()),
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active:
(input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID))
? { ...data.active, [input.serviceID]: account.id }
: data.active,
}
yield* write(next)
return [account, next] as const
}),
)
}),
update: Effect.fn("AuthV2.update")(function* (accountID, updates) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const existing = data.accounts[accountID]
if (!existing) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[accountID]: new Account({
id: accountID,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AuthV2.remove")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID)
delete active[accounts[accountID].serviceID]
delete accounts[accountID]
const next = { ...data, accounts, active }
yield* write(next)
return [undefined, next] as const
}),
)
}),
activate: Effect.fn("AuthV2.activate")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = data.accounts[accountID]
if (!account) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } }
yield* write(next)
return [undefined, next] as const
}),
)
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
export * as AuthV2 from "./auth"
+139 -53
View File
@@ -1,6 +1,6 @@
export * as Catalog from "./catalog" export * as Catalog from "./catalog"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect" import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
import { produce, type Draft } from "immer" import { produce, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin" import { PluginV2 } from "./plugin"
@@ -8,9 +8,9 @@ import { ProviderV2 } from "./provider"
import { Location } from "./location" import { Location } from "./location"
import { EventV2 } from "./event" import { EventV2 } from "./event"
type ProviderRecord = { export type ProviderRecord = {
provider: ProviderV2.Info provider: ProviderV2.Info
models: HashMap.HashMap<ModelV2.ID, ModelV2.Info> models: Map<ModelV2.ID, ModelV2.Info>
} }
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()( export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
@@ -34,10 +34,26 @@ export const Event = {
}), }),
} }
export type Context = {
data: readonly ProviderRecord[]
updateProvider: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
provider: {
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID) => void
}
model: {
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
}
}
export type Loader = (update: (ctx: Context) => void) => Effect.Effect<void>
export interface Interface { export interface Interface {
readonly loader: () => Effect.Effect<Loader, never, Scope.Scope>
readonly provider: { readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError> readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => Effect.Effect<void>
readonly all: () => Effect.Effect<ProviderV2.Info[]> readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]> readonly available: () => Effect.Effect<ProviderV2.Info[]>
} }
@@ -46,11 +62,6 @@ export interface Interface {
providerID: ProviderV2.ID, providerID: ProviderV2.ID,
modelID: ModelV2.ID, modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError> ) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly update: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
fn: (model: Draft<ModelV2.Info>) => void,
) => Effect.Effect<void, ProviderNotFoundError>
readonly all: () => Effect.Effect<ModelV2.Info[]> readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]> readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>> readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
@@ -69,9 +80,11 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
yield* Location.Service yield* Location.Service
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>() let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
let loaders: { update: (ctx: Context) => void }[] = []
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const resolve = (model: ModelV2.Info) => { const resolve = (model: ModelV2.Info) => {
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
@@ -112,29 +125,122 @@ export const layer = Layer.effect(
return match.value return match.value
} }
const normalizeEndpoint = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
if (item.endpoint.type !== "aisdk" || typeof item.options.aisdk.provider.baseURL !== "string") return
item.endpoint.url = item.options.aisdk.provider.baseURL
delete item.options.aisdk.provider.baseURL
}
const clone = (input: HashMap.HashMap<ProviderV2.ID, ProviderRecord>) =>
HashMap.fromIterable(HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const))
const context = (draft: { records: HashMap.HashMap<ProviderV2.ID, ProviderRecord>; data: ProviderRecord[] }): Context => {
const result: Context = {
data: draft.data,
updateProvider: (providerID, fn) => result.provider.update(providerID, fn),
updateModel: (providerID, modelID, fn) => result.model.update(providerID, modelID, fn),
provider: {
update: (providerID, fn) => {
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
fn(draft)
normalizeEndpoint(draft)
})
const next = {
provider,
models: current?.models ?? new Map<ModelV2.ID, ModelV2.Info>(),
}
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index === -1) draft.data.push(next)
else draft.data[index] = next
},
remove: (providerID) => {
draft.records = HashMap.remove(draft.records, providerID)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index !== -1) draft.data.splice(index, 1)
},
},
model: {
update: (providerID, modelID, fn) => {
const current = Option.getOrThrow(HashMap.get(draft.records, providerID))
const model = produce(
current.models.get(modelID) ?? ModelV2.Info.empty(providerID, modelID),
(draft) => {
fn(draft)
normalizeEndpoint(draft)
},
)
const next = {
provider: current.provider,
models: new Map(current.models).set(modelID, new ModelV2.Info({ ...model, id: modelID, providerID })),
}
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index === -1) draft.data.push(next)
else draft.data[index] = next
},
remove: (providerID, modelID) => {
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
if (!current) return
const next = {
provider: current.provider,
models: new Map(current.models),
}
next.models.delete(modelID)
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index !== -1) draft.data[index] = next
},
},
}
return result
}
const transform = Effect.fn("CatalogV2.transform")(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.trigger("catalog.transform", context(draft), {})
records = draft.records
})
const rebuild = Effect.fn("CatalogV2.rebuild")(function* () {
const draft = { records: HashMap.empty<ProviderV2.ID, ProviderRecord>(), data: [] as ProviderRecord[] }
for (const loader of loaders) loader.update(context(draft))
yield* plugin.trigger("catalog.transform", context(draft), {})
records = draft.records
})
yield* plugin.added().pipe(
Stream.runForEach((id) =>
Effect.gen(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.triggerFor(id, "catalog.transform", context(draft), {})
records = draft.records
}),
),
Effect.forkIn(scope, { startImmediately: true }),
)
const result: Interface = { const result: Interface = {
loader: Effect.fn("CatalogV2.loader")(function* () {
const loader = { update: (_ctx: Context) => {} }
loaders = [...loaders, loader]
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(scope, Effect.sync(() => {
loaders = loaders.filter((item) => item !== loader)
}).pipe(Effect.andThen(rebuild())))
return Effect.fnUntraced(function* (update) {
loader.update = update
yield* rebuild()
})
}),
provider: { provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID) const record = yield* getRecord(providerID)
return record.provider return record.provider
}), }),
update: Effect.fnUntraced(function* (providerID, fn) {
const current = Option.getOrUndefined(HashMap.get(records, providerID))
const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
fn(draft)
if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
draft.endpoint.url = draft.options.aisdk.provider.baseURL
delete draft.options.aisdk.provider.baseURL
}
})
const updated = yield* plugin.trigger("provider.update", {}, { provider, cancel: false })
records = HashMap.set(records, providerID, {
provider: updated.provider,
models: current?.models ?? HashMap.empty<ModelV2.ID, ModelV2.Info>(),
})
}),
all: Effect.fn("CatalogV2.provider.all")(function* () { all: Effect.fn("CatalogV2.provider.all")(function* () {
return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider) return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider)
}), }),
@@ -149,39 +255,16 @@ export const layer = Layer.effect(
model: { model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) { get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = yield* getRecord(providerID) const record = yield* getRecord(providerID)
const model = Option.getOrUndefined(HashMap.get(record.models, modelID)) const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return resolve(model) return resolve(model)
}), }),
update: Effect.fnUntraced(function* (providerID, modelID, fn) {
const record = yield* getRecord(providerID)
const model = produce(
HashMap.get(record.models, modelID).pipe(Option.getOrElse(() => ModelV2.Info.empty(providerID, modelID))),
(draft) => {
fn(draft)
if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
draft.endpoint.url = draft.options.aisdk.provider.baseURL
delete draft.options.aisdk.provider.baseURL
}
},
)
const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
if (updated.cancel) return
const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
records = HashMap.set(records, providerID, {
provider: record.provider,
models: HashMap.set(record.models, modelID, next),
})
yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
return
}),
all: Effect.fn("CatalogV2.model.all")(function* () { all: Effect.fn("CatalogV2.model.all")(function* () {
return pipe( return pipe(
records, records,
HashMap.toValues, HashMap.toValues,
Array.flatMap((record) => HashMap.toValues(record.models)), Array.flatMap((record) => globalThis.Array.from(record.models.values())),
Array.map(resolve), Array.map(resolve),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
) )
@@ -217,12 +300,12 @@ export const layer = Layer.effect(
if (!record) return Option.none<ModelV2.Info>() if (!record) return Option.none<ModelV2.Info>()
if (providerID === ProviderV2.ID.opencode) { if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = Option.getOrUndefined(HashMap.get(record.models, ModelV2.ID.make("gpt-5-nano"))) const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano)) if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano))
} }
const candidates = pipe( const candidates = pipe(
HashMap.toValues(record.models), globalThis.Array.from(record.models.values()),
Array.filter( Array.filter(
(model) => (model) =>
model.providerID === providerID && model.providerID === providerID &&
@@ -266,4 +349,7 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer)) export const defaultLayer = layer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(PluginV2.defaultLayer),
)
+2 -2
View File
@@ -1,3 +1,5 @@
export * as EventV2 from "./event"
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Location } from "./location" import { Location } from "./location"
import { withStatics } from "./schema" import { withStatics } from "./schema"
@@ -153,5 +155,3 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer export const defaultLayer = layer
export * as EventV2 from "./event"
+5 -4
View File
@@ -4,9 +4,10 @@ import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot" import { PluginBoot } from "./plugin/boot"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", { export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => { lookup: (ref: Location.Ref) =>
const location = Layer.succeed(Location.Service, Location.Service.of(ref)) Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location)) Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]),
}, ),
idleTimeToLive: "5 minutes", idleTimeToLive: "5 minutes",
dependencies: [],
}) {} }) {}
+12 -1
View File
@@ -7,6 +7,7 @@ import { Flock } from "./util/flock"
import { Hash } from "./util/hash" import { Hash } from "./util/hash"
import { AppFileSystem } from "./filesystem" import { AppFileSystem } from "./filesystem"
import { InstallationChannel, InstallationVersion } from "./installation/version" import { InstallationChannel, InstallationVersion } from "./installation/version"
import { EventV2 } from "./event"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -105,6 +106,13 @@ export const Provider = Schema.Struct({
export type Provider = Schema.Schema.Type<typeof Provider> export type Provider = Schema.Schema.Type<typeof Provider>
export const Event = {
Refreshed: EventV2.define({
type: "models-dev.refreshed",
schema: {},
}),
}
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
export interface Interface { export interface Interface {
@@ -118,6 +126,7 @@ export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const events = yield* EventV2.Service
const http = HttpClient.filterStatusOk( const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe( (yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({ HttpClient.retryTransient({
@@ -197,6 +206,7 @@ export const layer = Layer.effect(
if (!force && (yield* fresh())) return if (!force && (yield* fresh())) return
yield* fetchAndWrite() yield* fetchAndWrite()
yield* invalidate yield* invalidate
yield* events.publish(Event.Refreshed, {})
}), }),
).pipe( ).pipe(
Effect.tapCause((cause) => Effect.tapCause((cause) =>
@@ -215,9 +225,10 @@ export const layer = Layer.effect(
}), }),
) )
export const defaultLayer: Layer.Layer<Service> = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(EventV2.defaultLayer),
) )
export * as ModelsDev from "./models-dev" export * as ModelsDev from "./models-dev"
+45
View File
@@ -0,0 +1,45 @@
export * as PermissionV2 from "./permission"
import { Schema } from "effect"
import { Wildcard } from "./util/wildcard"
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Action" })
export type Action = typeof Action.Type
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type
const EDIT_TOOLS = ["edit", "write", "apply_patch"]
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
}
)
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return new Set(
tools.filter((tool) => {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
}
+63 -18
View File
@@ -2,27 +2,26 @@ export * as PluginV2 from "./plugin"
import { createDraft, finishDraft, type Draft } from "immer" import { createDraft, finishDraft, type Draft } from "immer"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { type ProviderV2 } from "./provider" import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import type { ModelV2 } from "./model" import type { ModelV2 } from "./model"
import type { AgentV2 } from "./agent"
import type { Catalog } from "./catalog"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export type ID = typeof ID.Type export type ID = typeof ID.Type
type HookSpec = { type HookSpec = {
"provider.update": { "catalog.transform": {
input: {} input: Catalog.Context
output: { output: {}
provider: ProviderV2.Info
cancel: boolean
}
} }
"model.update": { "account.switched": {
input: {} input: {
output: { serviceID: import("./account").AccountV2.ServiceID
model: ModelV2.Info from?: import("./account").AccountV2.ID
cancel: boolean to?: import("./account").AccountV2.ID
} }
output: {}
} }
"aisdk.language": { "aisdk.language": {
input: { input: {
@@ -44,6 +43,27 @@ type HookSpec = {
sdk?: any sdk?: any
} }
} }
"agent.update": {
input: {}
output: {
agent: AgentV2.Info
cancel: boolean
}
}
"agent.remove": {
input: {
agent: AgentV2.Info
}
output: {
cancel: boolean
}
}
"agent.default": {
input: {}
output: {
agent?: AgentV2.ID
}
}
} }
export type Hooks = { export type Hooks = {
@@ -61,15 +81,25 @@ export type HookFunctions = {
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"] export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"] export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
export type Effect = Effect.Effect<HookFunctions | void, never, never> export type Effect<R = never> = Effect.Effect<HookFunctions | void, never, R | Scope.Scope>
export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) { export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) {
return input return input
} }
export interface Interface { export interface Interface {
readonly add: (input: { id: ID; effect: Effect }) => Effect.Effect<void> readonly add: (input: {
id: ID
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
}) => Effect.Effect<void, never, never>
readonly remove: (id: ID) => Effect.Effect<void> readonly remove: (id: ID) => Effect.Effect<void>
readonly added: () => Stream.Stream<ID>
readonly triggerFor: <Name extends keyof Hooks>(
id: ID,
name: Name,
input: HookInput<Name>,
output: HookOutput<Name>,
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
readonly trigger: <Name extends keyof Hooks>( readonly trigger: <Name extends keyof Hooks>(
name: Name, name: Name,
input: HookInput<Name>, input: HookInput<Name>,
@@ -85,21 +115,33 @@ export const layer = Layer.effect(
let hooks: { let hooks: {
id: ID id: ID
hooks: HookFunctions hooks: HookFunctions
scope: Scope.Closeable
}[] = [] }[] = []
const added = yield* PubSub.unbounded<ID>()
yield* Effect.addFinalizer(() => PubSub.shutdown(added))
const svc = Service.of({ const svc = Service.of({
add: Effect.fn("Plugin.add")(function* (input) { add: Effect.fn("Plugin.add")(function* (input) {
const result = yield* input.effect const existing = hooks.find((item) => item.id === input.id)
if (!result) return if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
const scope = yield* Scope.make()
const result = yield* input.effect.pipe(Scope.provide(scope))
hooks = [ hooks = [
...hooks.filter((item) => item.id !== input.id), ...hooks.filter((item) => item.id !== input.id),
{ {
id: input.id, id: input.id,
hooks: result, hooks: result ?? {},
scope,
}, },
] ]
yield* PubSub.publish(added, input.id)
}), }),
added: () => Stream.fromPubSub(added),
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
return yield* svc.triggerFor(ID.make("*"), name, input, output)
}),
triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) {
const draftEntries = new Map<string, ReturnType<typeof createDraft>>() const draftEntries = new Map<string, ReturnType<typeof createDraft>>()
const event = { const event = {
...input, ...input,
@@ -114,6 +156,7 @@ export const layer = Layer.effect(
} }
for (const item of hooks) { for (const item of hooks) {
if (id !== ID.make("*") && item.id !== id) continue
const match = item.hooks[name] const match = item.hooks[name]
if (!match) continue if (!match) continue
yield* match(event as any).pipe( yield* match(event as any).pipe(
@@ -133,7 +176,9 @@ export const layer = Layer.effect(
return event as any return event as any
}), }),
remove: Effect.fn("Plugin.remove")(function* (id) { remove: Effect.fn("Plugin.remove")(function* (id) {
const existing = hooks.find((item) => item.id === id)
hooks = hooks.filter((item) => item.id !== id) hooks = hooks.filter((item) => item.id !== id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
}), }),
}) })
return svc return svc
+41
View File
@@ -0,0 +1,41 @@
import { Effect, Scope, Stream } from "effect"
import { AccountV2 } from "../account"
import { EventV2 } from "../event"
import { PluginV2 } from "../plugin"
export const AccountPlugin = PluginV2.define({
id: PluginV2.ID.make("account"),
effect: Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
yield* events.subscribe(AccountV2.Event.Switched).pipe(
Stream.runForEach((event) =>
PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid),
),
Effect.forkIn(scope, { startImmediately: true }),
)
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie)
if (!account) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {
via: "account",
service: account.serviceID,
}
if (account.credential.type === "api") {
provider.options.aisdk.provider.apiKey = account.credential.key
Object.assign(provider.options.aisdk.provider, account.credential.metadata ?? {})
}
if (account.credential.type === "oauth") provider.options.aisdk.provider.apiKey = account.credential.access
})
}
}),
"account.switched": Effect.fn(function* () {}),
}
}),
})
-27
View File
@@ -1,27 +0,0 @@
import { Effect } from "effect"
import { AuthV2 } from "../auth"
import { PluginV2 } from "../plugin"
export const AuthPlugin = PluginV2.define({
id: PluginV2.ID.make("auth"),
effect: Effect.gen(function* () {
const auth = yield* AuthV2.Service
return {
"provider.update": Effect.fn(function* (evt) {
const account = yield* auth.active(AuthV2.ServiceID.make(evt.provider.id)).pipe(Effect.orDie)
if (!account) return
evt.provider.enabled = {
via: "auth",
service: account.serviceID,
}
if (account.credential.type === "api") {
evt.provider.options.aisdk.provider.apiKey = account.credential.key
Object.assign(evt.provider.options.aisdk.provider, account.credential.metadata ?? {})
}
if (account.credential.type === "oauth") {
evt.provider.options.aisdk.provider.apiKey = account.credential.access
}
}),
}
}),
})
+51 -41
View File
@@ -1,18 +1,22 @@
export * as PluginBoot from "./boot" export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect" import { Context, Deferred, Effect, Layer, Scope } from "effect"
import { AuthV2 } from "../auth" import { AccountV2 } from "../account"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog" import { Catalog } from "../catalog"
import { EventV2 } from "../event"
import { Npm } from "../npm" import { Npm } from "../npm"
import { PluginV2 } from "../plugin" import { PluginV2 } from "../plugin"
import { AuthPlugin } from "./auth" import { AccountPlugin } from "./account"
import { EnvPlugin } from "./env" import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev" import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider" import { ProviderPlugins } from "./provider"
type Plugin = { type Plugin = {
id: PluginV2.ID id: PluginV2.ID
effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service> effect: PluginV2.Effect<
Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service
>
} }
export interface Interface { export interface Interface {
@@ -21,51 +25,57 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Service | AuthV2.Service | Npm.Service> = export const layer = Layer.effect(
Layer.effect( Service,
Service, Effect.gen(function* () {
Effect.gen(function* () { const agent = yield* AgentV2.Service
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service const accounts = yield* AccountV2.Service
const npm = yield* Npm.Service const npm = yield* Npm.Service
const done = yield* Deferred.make<void>() const events = yield* EventV2.Service
const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
yield* plugin.add({ yield* plugin.add({
id: input.id, id: input.id,
effect: input.effect.pipe( effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog), Effect.provideService(Catalog.Service, catalog),
Effect.provideService(AuthV2.Service, auth), Effect.provideService(AgentV2.Service, agent),
Effect.provideService(Npm.Service, npm), Effect.provideService(AccountV2.Service, accounts),
), Effect.provideService(Npm.Service, npm),
}) Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
}) })
})
const boot = Effect.gen(function* () { const boot = Effect.gen(function* () {
yield* add(EnvPlugin) yield* add(EnvPlugin)
yield* add(AuthPlugin) yield* add(AccountPlugin)
for (const item of ProviderPlugins) { for (const item of ProviderPlugins) {
yield* add(item) yield* add(item)
} }
yield* add(ModelsDevPlugin) yield* add(ModelsDevPlugin)
}).pipe(Effect.withSpan("PluginBoot.boot")) }).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe( yield* boot.pipe(
Effect.exit, Effect.exit,
Effect.flatMap((exit) => Deferred.done(done, exit)), Effect.flatMap((exit) => Deferred.done(done, exit)),
Effect.forkScoped, Effect.forkScoped,
) )
return Service.of({ return Service.of({
wait: () => Deferred.await(done), wait: () => Deferred.await(done),
}) })
}), }),
) )
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(AgentV2.defaultLayer),
Layer.provide(Catalog.defaultLayer), Layer.provide(Catalog.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(PluginV2.defaultLayer), Layer.provide(PluginV2.defaultLayer),
Layer.provide(Layer.orDie(AuthV2.defaultLayer)), Layer.provide(AccountV2.defaultLayer),
Layer.provide(Npm.defaultLayer), Layer.provide(Npm.defaultLayer),
) )
+10 -6
View File
@@ -5,12 +5,16 @@ export const EnvPlugin = PluginV2.define({
id: PluginV2.ID.make("env"), id: PluginV2.ID.make("env"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
const key = evt.provider.env.find((item) => process.env[item]) for (const item of evt.data) {
if (!key) return const key = item.provider.env.find((env) => process.env[env])
evt.provider.enabled = { if (!key) continue
via: "env", evt.provider.update(item.provider.id, (provider) => {
name: key, provider.enabled = {
via: "env",
name: key,
}
})
} }
}), }),
} }
+56 -44
View File
@@ -1,5 +1,6 @@
import { DateTime, Effect } from "effect" import { DateTime, Effect, Scope, Stream } from "effect"
import { Catalog } from "../catalog" import { Catalog } from "../catalog"
import { EventV2 } from "../event"
import { ModelV2 } from "../model" import { ModelV2 } from "../model"
import { ModelsDev } from "../models-dev" import { ModelsDev } from "../models-dev"
import { PluginV2 } from "../plugin" import { PluginV2 } from "../plugin"
@@ -54,55 +55,66 @@ export const ModelsDevPlugin = PluginV2.define({
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const modelsDev = yield* ModelsDev.Service const modelsDev = yield* ModelsDev.Service
for (const item of Object.values(yield* modelsDev.get())) { const events = yield* EventV2.Service
const providerID = ProviderV2.ID.make(item.id) const scope = yield* Scope.Scope
yield* catalog.provider.update(providerID, (provider) => { const load = yield* catalog.loader()
provider.name = item.name const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
provider.env = [...item.env] const data = yield* modelsDev.get()
provider.endpoint = item.npm yield* load((catalog) => {
? { for (const item of Object.values(data)) {
type: "aisdk", const providerID = ProviderV2.ID.make(item.id)
package: item.npm, catalog.provider.update(providerID, (provider) => {
url: item.api, provider.name = item.name
} provider.env = [...item.env]
: { provider.endpoint = item.npm
type: "unknown",
}
})
for (const model of Object.values(item.models)) {
const modelID = ModelV2.ID.make(model.id)
yield* catalog.model
.update(providerID, modelID, (draft) => {
draft.name = model.name
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
draft.endpoint = model.provider?.npm
? { ? {
type: "aisdk", type: "aisdk",
package: model.provider?.npm, package: item.npm,
url: model.provider.api, url: item.api,
} }
: { : {
type: "unknown", type: "unknown",
} }
draft.capabilities = {
tools: model.tool_call,
input: [...(model.modalities?.input ?? [])],
output: [...(model.modalities?.output ?? [])],
}
draft.variants = variants(model)
draft.time.released = released(model.release_date)
draft.cost = cost(model.cost)
draft.status = model.status ?? "active"
draft.enabled = true
draft.limit = {
context: model.limit.context,
input: model.limit.input,
output: model.limit.output,
}
}) })
.pipe(Effect.orDie)
} for (const model of Object.values(item.models)) {
} const modelID = ModelV2.ID.make(model.id)
catalog.model.update(providerID, modelID, (draft) => {
draft.name = model.name
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
draft.endpoint = model.provider?.npm
? {
type: "aisdk",
package: model.provider?.npm,
url: model.provider.api,
}
: {
type: "unknown",
}
draft.capabilities = {
tools: model.tool_call,
input: [...(model.modalities?.input ?? [])],
output: [...(model.modalities?.output ?? [])],
}
draft.variants = variants(model)
draft.time.released = released(model.release_date)
draft.cost = cost(model.cost)
draft.status = model.status ?? "active"
draft.enabled = true
draft.limit = {
context: model.limit.context,
input: model.limit.input,
output: model.limit.output,
}
})
}
}
})
})
yield* refresh()
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => refresh()),
Effect.forkIn(scope, { startImmediately: true }),
)
}).pipe(Effect.provide(ModelsDev.defaultLayer)), }).pipe(Effect.provide(ModelsDev.defaultLayer)),
}) })
@@ -50,14 +50,19 @@ export const AmazonBedrockPlugin = PluginV2.define({
id: PluginV2.ID.make("amazon-bedrock"), id: PluginV2.ID.make("amazon-bedrock"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.amazonBedrock) return for (const item of evt.data) {
if (evt.provider.endpoint.type !== "aisdk") return if (item.provider.endpoint.type !== "aisdk") continue
if (typeof evt.provider.options.aisdk.provider.endpoint !== "string") return if (item.provider.endpoint.package !== "@ai-sdk/amazon-bedrock") continue
// The AI SDK expects a base URL, but users configure Bedrock private/VPC evt.provider.update(item.provider.id, (provider) => {
// endpoints as `endpoint`; move it into the catalog endpoint URL once. if (provider.endpoint.type !== "aisdk") return
evt.provider.endpoint.url = evt.provider.options.aisdk.provider.endpoint if (typeof provider.options.aisdk.provider.endpoint !== "string") return
delete evt.provider.options.aisdk.provider.endpoint // 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.endpoint.url = provider.options.aisdk.provider.endpoint
delete provider.options.aisdk.provider.endpoint
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/amazon-bedrock") return if (evt.package !== "@ai-sdk/amazon-bedrock") return
@@ -1,15 +1,19 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const AnthropicPlugin = PluginV2.define({ export const AnthropicPlugin = PluginV2.define({
id: PluginV2.ID.make("anthropic"), id: PluginV2.ID.make("anthropic"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.anthropic) return for (const item of evt.data) {
evt.provider.options.headers["anthropic-beta"] = if (item.provider.endpoint.type !== "aisdk") continue
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" if (item.provider.endpoint.package !== "@ai-sdk/anthropic") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["anthropic-beta"] =
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return if (evt.package !== "@ai-sdk/anthropic") return
+22 -10
View File
@@ -14,12 +14,18 @@ export const AzurePlugin = PluginV2.define({
id: PluginV2.ID.make("azure"), id: PluginV2.ID.make("azure"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.azure) return for (const item of evt.data) {
const configured = evt.provider.options.aisdk.provider.resourceName if (item.provider.endpoint.type !== "aisdk") continue
const resourceName = if (item.provider.endpoint.package !== "@ai-sdk/azure") continue
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME const configured = item.provider.options.aisdk.provider.resourceName
if (resourceName) evt.provider.options.aisdk.provider.resourceName = 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.options.aisdk.provider.resourceName = resourceName
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return if (evt.package !== "@ai-sdk/azure") return
@@ -49,11 +55,17 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
id: PluginV2.ID.make("azure-cognitive-services"), id: PluginV2.ID.make("azure-cognitive-services"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("azure-cognitive-services")) return
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (resourceName) if (!resourceName) return
evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai` for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (!item.provider.id.includes("azure-cognitive-services")) continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
})
}
}), }),
"aisdk.language": Effect.fn(function* (evt) { "aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
@@ -1,14 +1,18 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const CerebrasPlugin = PluginV2.define({ export const CerebrasPlugin = PluginV2.define({
id: PluginV2.ID.make("cerebras"), id: PluginV2.ID.make("cerebras"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (ctx) {
if (evt.provider.id !== ProviderV2.ID.make("cerebras")) return for (const item of ctx.data) {
evt.provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode" if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/cerebras") continue
ctx.provider.update(item.provider.id, (provider) => {
provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cerebras") return if (evt.package !== "@ai-sdk/cerebras") return
@@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined { function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
// AuthPlugin copies CLI prompt metadata into options. The prompt stores the // AccountPlugin copies CLI prompt metadata into options. The prompt stores the
// gateway as gatewayId, while older config examples may use gateway. // gateway as gatewayId, while older config examples may use gateway.
const gatewayId = const gatewayId =
process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway") process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway")
@@ -10,13 +10,15 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
id: PluginV2.ID.make("cloudflare-workers-ai"), id: PluginV2.ID.make("cloudflare-workers-ai"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== providerID) return const item = evt.data.find((record) => record.provider.id === providerID)
if (evt.provider.endpoint.type !== "aisdk") return if (!item) return
if (evt.provider.endpoint.url) return evt.provider.update(item.provider.id, (provider) => {
if (provider.endpoint.type !== "aisdk") return
const accountId = resolveAccountId(evt.provider.options.aisdk.provider) if (provider.endpoint.url) return
if (accountId) evt.provider.endpoint.url = workersEndpoint(accountId) const accountId = resolveAccountId(provider.options.aisdk.provider)
if (accountId) provider.endpoint.url = workersEndpoint(accountId)
})
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return if (evt.model.providerID !== providerID) return
@@ -15,9 +15,6 @@ export const GithubCopilotPlugin = PluginV2.define({
id: PluginV2.ID.make("github-copilot"), id: PluginV2.ID.make("github-copilot"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.githubCopilot) return
}),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/github-copilot") return if (evt.package !== "@ai-sdk/github-copilot") return
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
@@ -33,11 +30,14 @@ export const GithubCopilotPlugin = PluginV2.define({
? evt.sdk.responses(evt.model.apiID) ? evt.sdk.responses(evt.model.apiID)
: evt.sdk.chat(evt.model.apiID) : evt.sdk.chat(evt.model.apiID)
}), }),
"model.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.githubCopilot)
// This chat-only alias conflicts with the Copilot GPT-5 Responses route, if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
// so hide it only for Copilot rather than for every provider catalog. evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true // 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
})
}), }),
} }
}), }),
@@ -57,20 +57,22 @@ export const GoogleVertexPlugin = PluginV2.define({
id: PluginV2.ID.make("google-vertex"), id: PluginV2.ID.make("google-vertex"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.googleVertex) return for (const item of evt.data) {
const project = resolveProject(evt.provider.options.aisdk.provider) if (item.provider.endpoint.type !== "aisdk") continue
const location = String(resolveLocation(evt.provider.options.aisdk.provider)) if (item.provider.endpoint.package !== "@ai-sdk/google-vertex" && !item.provider.endpoint.package.includes("@ai-sdk/openai-compatible")) continue
if (project) evt.provider.options.aisdk.provider.project = project const project = resolveProject(item.provider.options.aisdk.provider)
evt.provider.options.aisdk.provider.location = location const location = String(resolveLocation(item.provider.options.aisdk.provider))
if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.url) { evt.provider.update(item.provider.id, (provider) => {
evt.provider.endpoint.url = replaceVertexVars(evt.provider.endpoint.url, project, location) if (project) provider.options.aisdk.provider.project = project
} provider.options.aisdk.provider.location = location
if ( if (provider.endpoint.type === "aisdk" && provider.endpoint.url) {
evt.provider.endpoint.type === "aisdk" && provider.endpoint.url = replaceVertexVars(provider.endpoint.url, project, location)
evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible") }
) { if (provider.endpoint.type === "aisdk" && provider.endpoint.package.includes("@ai-sdk/openai-compatible")) {
evt.provider.options.aisdk.provider.fetch = authFetch(evt.provider.options.aisdk.provider.fetch) provider.options.aisdk.provider.fetch = authFetch(provider.options.aisdk.provider.fetch)
}
})
} }
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
@@ -102,20 +104,25 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
id: PluginV2.ID.make("google-vertex-anthropic"), id: PluginV2.ID.make("google-vertex-anthropic"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("google-vertex-anthropic")) return for (const item of evt.data) {
const project = if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.aisdk.provider.project ?? if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue
process.env.GOOGLE_CLOUD_PROJECT ?? const project =
process.env.GCP_PROJECT ?? item.provider.options.aisdk.provider.project ??
process.env.GCLOUD_PROJECT process.env.GOOGLE_CLOUD_PROJECT ??
const location = process.env.GCP_PROJECT ??
evt.provider.options.aisdk.provider.location ?? process.env.GCLOUD_PROJECT
process.env.GOOGLE_CLOUD_LOCATION ?? const location =
process.env.VERTEX_LOCATION ?? item.provider.options.aisdk.provider.location ??
"global" process.env.GOOGLE_CLOUD_LOCATION ??
if (project) evt.provider.options.aisdk.provider.project = project process.env.VERTEX_LOCATION ??
evt.provider.options.aisdk.provider.location = location "global"
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.options.aisdk.provider.project = project
provider.options.aisdk.provider.location = location
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
+10 -5
View File
@@ -1,15 +1,20 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const KiloPlugin = PluginV2.define({ export const KiloPlugin = PluginV2.define({
id: PluginV2.ID.make("kilo"), id: PluginV2.ID.make("kilo"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("kilo")) return for (const item of evt.data) {
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["X-Title"] = "opencode" if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.endpoint.url !== "https://api.kilo.ai/api/gateway") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.headers["X-Title"] = "opencode"
})
}
}), }),
} }
}), }),
@@ -1,17 +1,22 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const LLMGatewayPlugin = PluginV2.define({ export const LLMGatewayPlugin = PluginV2.define({
id: PluginV2.ID.make("llmgateway"), id: PluginV2.ID.make("llmgateway"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("llmgateway")) return for (const item of evt.data) {
if (evt.provider.enabled === false) return if (item.provider.enabled === false) continue
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["X-Title"] = "opencode" if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
evt.provider.options.headers["X-Source"] = "opencode" if (item.provider.endpoint.url !== "https://api.llmgateway.io/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.headers["X-Title"] = "opencode"
provider.options.headers["X-Source"] = "opencode"
})
}
}), }),
} }
}), }),
+11 -6
View File
@@ -1,16 +1,21 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const NvidiaPlugin = PluginV2.define({ export const NvidiaPlugin = PluginV2.define({
id: PluginV2.ID.make("nvidia"), id: PluginV2.ID.make("nvidia"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return for (const item of evt.data) {
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["X-Title"] = "opencode" if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
evt.provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode" if (item.provider.endpoint.url !== "https://integrate.api.nvidia.com/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.headers["X-Title"] = "opencode"
provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
})
}
}), }),
} }
}), }),
+11 -5
View File
@@ -16,11 +16,17 @@ export const OpenAIPlugin = PluginV2.define({
if (evt.model.providerID !== ProviderV2.ID.openai) return if (evt.model.providerID !== ProviderV2.ID.openai) return
evt.language = evt.sdk.responses(evt.model.apiID) evt.language = evt.sdk.responses(evt.model.apiID)
}), }),
"model.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openai) return for (const item of evt.data) {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a if (item.provider.endpoint.type !== "aisdk") continue
// chat-completions-only model, so remove it only from OpenAI's catalog. if (item.provider.endpoint.package !== "@ai-sdk/openai") continue
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true 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
})
}
}), }),
} }
}), }),
+15 -10
View File
@@ -7,20 +7,25 @@ export const OpencodePlugin = PluginV2.define({
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
let hasKey = false let hasKey = false
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.opencode) return const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.opencode)
if (!item) return
hasKey = Boolean( hasKey = Boolean(
process.env.OPENCODE_API_KEY || process.env.OPENCODE_API_KEY ||
evt.provider.env.some((item) => process.env[item]) || item.provider.env.some((env) => process.env[env]) ||
evt.provider.options.aisdk.provider.apiKey || item.provider.options.aisdk.provider.apiKey ||
(evt.provider.enabled && evt.provider.enabled.via === "auth"), (item.provider.enabled && item.provider.enabled.via === "account"),
) )
if (!hasKey) evt.provider.options.aisdk.provider.apiKey = "public" evt.provider.update(item.provider.id, (provider) => {
}), if (!hasKey) provider.options.aisdk.provider.apiKey = "public"
"model.update": Effect.fn(function* (evt) { })
if (evt.model.providerID !== ProviderV2.ID.opencode) return
if (hasKey) return if (hasKey) return
if (evt.model.cost.some((item) => item.input > 0)) evt.cancel = true for (const model of item.models.values()) {
if (!model.cost.some((cost) => cost.input > 0)) continue
evt.model.update(item.provider.id, model.id, (draft) => {
draft.enabled = false
})
}
}), }),
} }
}), }),
+17 -12
View File
@@ -1,29 +1,34 @@
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const OpenRouterPlugin = PluginV2.define({ export const OpenRouterPlugin = PluginV2.define({
id: PluginV2.ID.make("openrouter"), id: PluginV2.ID.make("openrouter"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.openrouter) return for (const item of evt.data) {
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["X-Title"] = "opencode" if (item.provider.endpoint.package !== "@openrouter/ai-sdk-provider") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.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
})
}
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
evt.sdk = mod.createOpenRouter(evt.options) evt.sdk = mod.createOpenRouter(evt.options)
}), }),
"model.update": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openrouter) return
// These are OpenRouter-specific OpenAI chat aliases that do not work on
// the generic path. Keep custom providers with matching IDs untouched.
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
if (evt.model.id === ModelV2.ID.make("openai/gpt-5-chat")) evt.cancel = true
}),
} }
}), }),
}) })
+9 -4
View File
@@ -6,10 +6,15 @@ export const VercelPlugin = PluginV2.define({
id: PluginV2.ID.make("vercel"), id: PluginV2.ID.make("vercel"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("vercel")) return for (const item of evt.data) {
evt.provider.options.headers["http-referer"] = "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["x-title"] = "opencode" if (item.provider.endpoint.package !== "@ai-sdk/vercel") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["http-referer"] = "https://opencode.ai/"
provider.options.headers["x-title"] = "opencode"
})
}
}), }),
"aisdk.sdk": Effect.fn(function* (evt) { "aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/vercel") return if (evt.package !== "@ai-sdk/vercel") return
+10 -5
View File
@@ -1,15 +1,20 @@
import { Effect } from "effect" import { Effect } from "effect"
import { PluginV2 } from "../../plugin" import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const ZenmuxPlugin = PluginV2.define({ export const ZenmuxPlugin = PluginV2.define({
id: PluginV2.ID.make("zenmux"), id: PluginV2.ID.make("zenmux"),
effect: Effect.gen(function* () { effect: Effect.gen(function* () {
return { return {
"provider.update": Effect.fn(function* (evt) { "catalog.transform": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("zenmux")) return for (const item of evt.data) {
evt.provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/" if (item.provider.endpoint.type !== "aisdk") continue
evt.provider.options.headers["X-Title"] ??= "opencode" if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.endpoint.url !== "https://zenmux.ai/api/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/"
provider.options.headers["X-Title"] ??= "opencode"
})
}
}), }),
} }
}), }),
+1 -1
View File
@@ -86,7 +86,7 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
name: Schema.String, name: Schema.String,
}), }),
Schema.Struct({ Schema.Struct({
via: Schema.Literal("auth"), via: Schema.Literal("account"),
service: Schema.String, service: Schema.String,
}), }),
Schema.Struct({ Schema.Struct({
+14
View File
@@ -0,0 +1,14 @@
export * as Wildcard from "./wildcard"
export function match(input: string, pattern: string) {
const normalized = input.replaceAll("\\", "/")
let escaped = pattern
.replaceAll("\\", "/")
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")
if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?"
return new RegExp("^" + escaped + "$", process.platform === "win32" ? "si" : "s").test(normalized)
}
+284
View File
@@ -0,0 +1,284 @@
import path from "path"
import { describe, expect } from "bun:test"
import { produce } from "immer"
import { Effect, Fiber, Layer, Option, Stream } from "effect"
import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(PluginV2.defaultLayer)
function context(
records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>,
): Catalog.Context {
return {
data: records,
updateProvider: (providerID, fn) => context(records, updates).provider.update(providerID, fn),
updateModel: (providerID, modelID, fn) => context(records, updates).model.update(providerID, modelID, fn),
provider: {
update: (providerID, fn) => {
const record = records.find((item) => item.provider.id === providerID)
const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
if (record) record.provider = provider
else records.push({ provider, models: new Map<ModelV2.ID, ModelV2.Info>() })
updates.push({
id: providerID,
enabled: provider.enabled,
apiKey:
typeof provider.options.aisdk.provider.apiKey === "string"
? provider.options.aisdk.provider.apiKey
: undefined,
})
},
remove: (providerID) => {
const index = records.findIndex((item) => item.provider.id === providerID)
if (index !== -1) records.splice(index, 1)
},
},
model: {
update: () => {},
remove: () => {},
},
}
}
function testLayer(dir: string) {
return AccountV2.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(
Global.layerWith({
data: dir,
cache: path.join(dir, "cache"),
config: path.join(dir, "config"),
state: path.join(dir, "state"),
tmp: path.join(dir, "tmp"),
bin: path.join(dir, "bin"),
log: path.join(dir, "log"),
repos: path.join(dir, "repos"),
}),
),
)
}
describe("AccountV2", () => {
it.live("emits account lifecycle events", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const eventSvc = yield* EventV2.Service
const addedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Added)
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
const switchedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
const removedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Removed)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "raw-key" }),
})
expect(first).toBeDefined()
if (!first) return
expect(first.description).toBe("default")
expect(first.credential.type).toBe("api")
if (first.credential.type === "api") expect(first.credential.key).toBe("raw-key")
yield* accounts.update(first.id, { description: "keep" })
const updated = yield* accounts.get(first.id)
expect(updated?.description).toBe("keep")
expect(updated?.credential.type).toBe("api")
if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key")
const second = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
})
expect(second).toBeDefined()
if (!second) return
yield* accounts.remove(second.id)
const added = Array.from(yield* Fiber.join(addedFiber))
const switched = Array.from(yield* Fiber.join(switchedFiber))
const removed = Array.from(yield* Fiber.join(removedFiber))
expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id])
expect(switched.map((event) => event.data)).toEqual([
{ serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: first.id },
])
expect(removed[0]?.data.account.id).toBe(second.id)
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("always switches to newly created accounts", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const eventSvc = yield* EventV2.Service
const switchedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }),
})
const second = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
})
const third = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "third-key" }),
})
expect(first).toBeDefined()
expect(second).toBeDefined()
expect(third).toBeDefined()
if (!first || !second || !third) return
expect((yield* accounts.active(AccountV2.ServiceID.make("provider")))?.id).toBe(third.id)
expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
{ serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: third.id },
])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("account plugin refreshes providers on account lifecycle events", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const plugin = yield* PluginV2.Service
const records = [
{
provider: ProviderV2.Info.empty(ProviderV2.ID.make("provider")),
models: new Map<ModelV2.ID, ModelV2.Info>(),
},
]
const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
const catalog = Catalog.Service.of({
loader: () => Effect.die("unexpected catalog.loader"),
provider: {
get: () => Effect.die("unexpected provider.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.die("unexpected model.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
})
const eventSvc = yield* EventV2.Service
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, eventSvc),
Effect.provideService(PluginV2.Service, plugin),
),
})
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }),
})
expect(first).toBeDefined()
if (!first) return
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "first-key",
},
])
updates.length = 0
const second = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
})
expect(second).toBeDefined()
if (!second) return
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "second-key",
},
])
updates.length = 0
yield* accounts.activate(first.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "first-key",
},
])
updates.length = 0
yield* accounts.remove(first.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "second-key",
},
])
updates.length = 0
yield* accounts.remove(second.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
})
+97 -117
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect" import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@@ -22,24 +22,20 @@ describe("CatalogV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) =>
provider.endpoint = { catalog.provider.update(providerID, (provider) => {
type: "aisdk", provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://default.example.com" }
package: "@ai-sdk/openai-compatible", provider.options.aisdk.provider.baseURL = "https://override.example.com"
url: "https://default.example.com", }),
} )
provider.options.aisdk.provider.baseURL = "https://override.example.com"
})
const provider = yield* catalog.provider.get(providerID) expect((yield* catalog.provider.get(providerID)).endpoint).toEqual({
expect(provider.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://override.example.com", url: "https://override.example.com",
}) })
expect(provider.options.aisdk.provider.baseURL).toBeUndefined()
}), }),
) )
@@ -48,56 +44,23 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model") const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) => {
provider.endpoint = { catalog.provider.update(providerID, (provider) => {
type: "aisdk", provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://provider.example.com" }
package: "@ai-sdk/openai-compatible", })
url: "https://provider.example.com", catalog.model.update(providerID, modelID, (model) => {
} model.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://model.example.com" }
}) model.options.aisdk.provider.baseURL = "https://override.example.com"
yield* catalog.model.update(providerID, modelID, (model) => { })
model.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://model.example.com",
}
model.options.aisdk.provider.baseURL = "https://override.example.com"
}) })
const model = yield* catalog.model.get(providerID, modelID) expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
expect(model.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://override.example.com", url: "https://override.example.com",
}) })
expect(model.options.aisdk.provider.baseURL).toBeUndefined()
}),
)
it.effect("publishes model updated events", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const fiber = yield* events
.subscribe(Catalog.Event.ModelUpdated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* catalog.provider.update(providerID, () => {})
yield* catalog.model.update(providerID, modelID, (model) => {
model.name = "Updated Model"
})
const event = Array.from(yield* Fiber.join(fiber))[0]
expect(event?.type).toBe("catalog.model.updated")
expect(event?.data.model.providerID).toBe(providerID)
expect(event?.data.model.id).toBe(modelID)
expect(event?.data.model.name).toBe("Updated Model")
expect(event?.location).toEqual({ directory: "test" })
}), }),
) )
@@ -106,19 +69,16 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model") const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) => {
provider.endpoint = { catalog.provider.update(providerID, (provider) => {
type: "aisdk", provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://provider.example.com" }
package: "@ai-sdk/openai-compatible", })
url: "https://provider.example.com", catalog.model.update(providerID, modelID, () => {})
}
}) })
yield* catalog.model.update(providerID, modelID, () => {})
const model = yield* catalog.model.get(providerID, modelID) expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
expect(model.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com", url: "https://provider.example.com",
@@ -126,58 +86,82 @@ describe("CatalogV2", () => {
}), }),
) )
it.effect("runs provider hooks after baseURL is normalized", () => it.effect("runs catalog transform hooks after baseURL is normalized", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const seen: unknown[] = [] const seen: unknown[] = []
const load = yield* catalog.loader()
yield* plugin.add({ yield* plugin.add({
id: PluginV2.ID.make("test"), id: PluginV2.ID.make("test"),
effect: Effect.succeed({ effect: Effect.succeed({
"provider.update": (evt) => "catalog.transform": (evt) =>
Effect.sync(() => { Effect.sync(() => {
seen.push(evt.provider.endpoint.type) const item = evt.data.find((record) => record.provider.id === providerID)
if (evt.provider.endpoint.type === "aisdk") seen.push(evt.provider.endpoint.url) if (!item) return
seen.push(evt.provider.options.aisdk.provider.baseURL) seen.push(item.provider.endpoint.type)
if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url)
seen.push(item?.provider.options.aisdk.provider.baseURL)
}), }),
}), }),
}) })
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) =>
provider.endpoint = { catalog.provider.update(providerID, (provider) => {
type: "aisdk", provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
package: "@ai-sdk/openai-compatible", provider.options.aisdk.provider.baseURL = "https://provider.example.com"
} }),
provider.options.aisdk.provider.baseURL = "https://provider.example.com" )
})
expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined]) expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined])
}), }),
) )
it.effect("runs catalog transform when a plugin is added", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(providerID, (provider) => { provider.name = "Before" }))
yield* plugin.add({
id: PluginV2.ID.make("test-transform"),
effect: Effect.succeed({
"catalog.transform": (evt) => Effect.sync(() => evt.provider.update(providerID, (provider) => { provider.name = "After" })),
}),
})
yield* Effect.yieldNow
expect((yield* catalog.provider.get(providerID)).name).toBe("After")
}),
)
it.effect("resolves provider and model option merges", () => it.effect("resolves provider and model option merges", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model") const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) => {
provider.options.headers.provider = "provider" catalog.provider.update(providerID, (provider) => {
provider.options.headers.shared = "provider" provider.options.headers.provider = "provider"
provider.options.body.provider = true provider.options.headers.shared = "provider"
provider.options.aisdk.provider.provider = true provider.options.body.provider = true
}) provider.options.aisdk.provider.provider = true
yield* catalog.model.update(providerID, modelID, (model) => { })
model.options.headers.model = "model" catalog.model.update(providerID, modelID, (model) => {
model.options.headers.shared = "model" model.options.headers.model = "model"
model.options.body.model = true model.options.headers.shared = "model"
model.options.aisdk.provider.model = true model.options.body.model = true
model.options.aisdk.request.request = true model.options.aisdk.provider.model = true
model.options.aisdk.request.request = true
})
}) })
const model = yield* catalog.model.get(providerID, modelID) const model = yield* catalog.model.get(providerID, modelID)
expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" }) expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
expect(model.options.body).toEqual({ provider: true, model: true }) expect(model.options.body).toEqual({ provider: true, model: true })
expect(model.options.aisdk.provider).toEqual({ provider: true, model: true }) expect(model.options.aisdk.provider).toEqual({ provider: true, model: true })
@@ -189,20 +173,15 @@ describe("CatalogV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, (provider) => { yield* load((catalog) => {
provider.enabled = { via: "custom", data: {} } catalog.provider.update(providerID, (provider) => { provider.enabled = { via: "custom", data: {} } })
}) catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { model.time.released = DateTime.makeUnsafe(1000) })
yield* catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => { model.time.released = DateTime.makeUnsafe(2000) })
model.time.released = DateTime.makeUnsafe(1000)
})
yield* catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
model.time.released = DateTime.makeUnsafe(2000)
}) })
const model = yield* catalog.model.default() expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new")
expect(Option.getOrUndefined(model)?.id).toMatch("new")
}), }),
) )
@@ -210,24 +189,25 @@ describe("CatalogV2", () => {
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test") const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* catalog.provider.update(providerID, () => {}) yield* load((catalog) => {
yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { catalog.provider.update(providerID, () => {})
model.capabilities.input = ["text"] catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
model.capabilities.output = ["text"] model.capabilities.input = ["text"]
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] model.capabilities.output = ["text"]
model.time.released = DateTime.makeUnsafe(Date.now()) model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
}) model.time.released = DateTime.makeUnsafe(Date.now())
yield* catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { })
model.capabilities.input = ["text"] catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
model.capabilities.output = ["text"] model.capabilities.input = ["text"]
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] model.capabilities.output = ["text"]
model.time.released = DateTime.makeUnsafe(Date.now()) model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
model.time.released = DateTime.makeUnsafe(Date.now())
})
}) })
const model = yield* catalog.model.small(providerID) expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
expect(Option.getOrUndefined(model)?.id).toMatch("expensive-mini")
}), }),
) )
}) })
+2
View File
@@ -5,6 +5,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models-dev" import { ModelsDev } from "@opencode-ai/core/models-dev"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect" import { it } from "./lib/effect"
import { rm, writeFile, utimes, mkdir } from "fs/promises" import { rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path" import path from "path"
@@ -92,6 +93,7 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
Layer.fresh(ModelsDev.layer).pipe( Layer.fresh(ModelsDev.layer).pipe(
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(EventV2.defaultLayer),
) )
const writeCache = (data: object, mtimeMs?: number) => const writeCache = (data: object, mtimeMs?: number) =>
@@ -1,7 +1,9 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock" import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") { function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
@@ -20,27 +22,30 @@ describe("AmazonBedrockPlugin", () => {
it.effect("moves endpoint option to endpoint URL", () => it.effect("moves endpoint option to endpoint URL", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AmazonBedrockPlugin) yield* plugin.add(AmazonBedrockPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const bedrock = provider("amazon-bedrock", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
provider: provider("amazon-bedrock", { options: {
options: { headers: {},
headers: {}, body: {},
body: {}, aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} },
aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} }, },
}, })
}), catalog.provider.update(bedrock.id, (item) => {
cancel: false, item.endpoint = bedrock.endpoint
}, item.options = bedrock.options
) })
expect(result.provider.endpoint).toEqual({ })
const result = yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)
expect(result.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "@ai-sdk/amazon-bedrock",
url: "https://bedrock.example", url: "https://bedrock.example",
}) })
expect(result.provider.options.aisdk.provider.endpoint).toBeUndefined() expect(result.options.aisdk.provider.endpoint).toBeUndefined()
}), }),
) )
@@ -1,37 +1,43 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic" import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider } from "./provider-helper" import { it, model, provider } from "./provider-helper"
describe("AnthropicPlugin", () => { describe("AnthropicPlugin", () => {
it.effect("applies legacy beta headers", () => it.effect("applies legacy beta headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AnthropicPlugin) yield* plugin.add(AnthropicPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("anthropic", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" },
provider: provider("anthropic", { options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, })
}), catalog.provider.update(item.id, (draft) => {
cancel: false, draft.endpoint = item.endpoint
}, draft.options = item.options
) })
expect(result.provider.options.headers["anthropic-beta"]).toBe( })
expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers["anthropic-beta"]).toBe(
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
) )
expect(result.provider.options.headers.Existing).toBe("1") expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers.Existing).toBe("1")
}), }),
) )
it.effect("ignores non-Anthropic providers", () => it.effect("ignores non-Anthropic providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AnthropicPlugin) yield* plugin.add(AnthropicPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.headers["anthropic-beta"]).toBeUndefined() yield* load((catalog) => catalog.provider.update(provider("openai").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.headers["anthropic-beta"]).toBeUndefined()
}), }),
) )
@@ -1,7 +1,9 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure" import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
describe("AzureCognitiveServicesPlugin", () => { describe("AzureCognitiveServicesPlugin", () => {
@@ -9,20 +11,22 @@ describe("AzureCognitiveServicesPlugin", () => {
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzureCognitiveServicesPlugin) yield* plugin.add(AzureCognitiveServicesPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
{ provider: provider("azure-cognitive-services"), cancel: false }, item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
) })
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
}) })
expect(result.provider.options.aisdk.provider.baseURL).toBe( const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
"https://cognitive.cognitiveservices.azure.com/openai", expect(result.endpoint).toEqual({
) type: "aisdk",
expect(result.provider.options.aisdk.provider.resourceName).toBeUndefined() package: "@ai-sdk/openai-compatible",
url: "https://cognitive.cognitiveservices.azure.com/openai",
})
expect(result.options.aisdk.provider.baseURL).toBeUndefined()
expect(result.options.aisdk.provider.resourceName).toBeUndefined()
}), }),
), ),
) )
@@ -31,17 +35,27 @@ describe("AzureCognitiveServicesPlugin", () => {
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () => withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzureCognitiveServicesPlugin) yield* plugin.add(AzureCognitiveServicesPlugin)
const azure = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const azure = provider("azure-cognitive-services", {
{ provider: provider("azure-cognitive-services"), cancel: false }, endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
) })
const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) const openai = provider("openai")
expect(azure.provider.options.aisdk.provider.baseURL).toBeUndefined() catalog.provider.update(azure.id, (item) => {
expect(azure.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" }) item.endpoint = azure.endpoint
expect(other.provider.options.aisdk.provider.baseURL).toBeUndefined() })
expect(other.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" }) catalog.provider.update(openai.id, (item) => {
item.endpoint = openai.endpoint
})
})
const azure = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
const openai = yield* catalog.provider.get(ProviderV2.ID.openai)
expect(azure.options.aisdk.provider.baseURL).toBeUndefined()
expect(azure.endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" })
expect(openai.options.aisdk.provider.baseURL).toBeUndefined()
expect(openai.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
}), }),
), ),
) )
@@ -1,22 +1,40 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth" import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth" import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(npmLayer),
),
)
describe("AzurePlugin", () => { describe("AzurePlugin", () => {
it.effect("resolves resourceName from env", () => it.effect("resolves resourceName from env", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin) yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") yield* load((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
}), }),
), ),
) )
@@ -25,25 +43,29 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin) yield* plugin.add(AzurePlugin)
const azure = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const azure = provider("azure", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
provider: provider("azure", { options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } }, })
}), catalog.provider.update(azure.id, (item) => {
cancel: false, item.endpoint = azure.endpoint
}, item.options = azure.options
})
catalog.provider.update(ProviderV2.ID.openai, () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe(
"from-config",
) )
const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.resourceName).toBeUndefined()
expect(azure.provider.options.aisdk.provider.resourceName).toBe("from-config")
expect(other.provider.options.aisdk.provider.resourceName).toBeUndefined()
}), }),
), ),
) )
itWithAuth.effect("prefers auth resourceName over env", () => itWithAccount.effect("prefers account resourceName over env", () =>
withEnv( withEnv(
{ {
AZURE_RESOURCE_NAME: "from-env", AZURE_RESOURCE_NAME: "from-env",
@@ -51,23 +73,36 @@ describe("AzurePlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service const accounts = yield* AccountV2.Service
yield* auth.create({ const catalog = yield* Catalog.Service
serviceID: AuthV2.ServiceID.make("azure"), const events = yield* EventV2.Service
credential: new AuthV2.ApiKeyCredential({ yield* accounts.create({
serviceID: AccountV2.ServiceID.make("azure"),
credential: new AccountV2.ApiKeyCredential({
type: "api", type: "api",
key: "key", key: "key",
metadata: { resourceName: "from-auth" }, metadata: { resourceName: "from-account" },
}), }),
active: true,
}) })
yield* plugin.add({ yield* plugin.add({
...AuthPlugin, ...AccountPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
}) })
yield* plugin.add(AzurePlugin) yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-auth") yield* load((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe(
"from-account",
)
}), }),
), ),
) )
@@ -76,18 +111,20 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin) yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const azure = provider("azure", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
provider: provider("azure", { options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } }, })
}), catalog.provider.update(azure.id, (item) => {
cancel: false, item.endpoint = azure.endpoint
}, item.options = azure.options
) })
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") })
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
}), }),
), ),
) )
@@ -96,18 +133,20 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin) yield* plugin.add(AzurePlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const azure = provider("azure", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
provider: provider("azure", { options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } }, })
}), catalog.provider.update(azure.id, (item) => {
cancel: false, item.endpoint = azure.endpoint
}, item.options = azure.options
) })
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env") })
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
}), }),
), ),
) )
@@ -1,7 +1,9 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras" import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider } from "./provider-helper" import { it, model, provider } from "./provider-helper"
const cerebrasOptions: Record<string, unknown>[] = [] const cerebrasOptions: Record<string, unknown>[] = []
@@ -20,27 +22,27 @@ describe("CerebrasPlugin", () => {
it.effect("applies the legacy integration header", () => it.effect("applies the legacy integration header", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CerebrasPlugin) yield* plugin.add(CerebrasPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
{ item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" }
provider: provider("cerebras", { item.options.headers.Existing = "1"
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, })
}), })
cancel: false, expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" })
},
)
expect(result.provider.options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" })
}), }),
) )
it.effect("ignores non-Cerebras providers", () => it.effect("ignores non-Cerebras providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CerebrasPlugin) yield* plugin.add(CerebrasPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("groq"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.headers).toEqual({}) yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).options.headers).toEqual({})
}), }),
) )
@@ -1,14 +1,26 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth" import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth" import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(npmLayer),
),
)
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
@@ -34,22 +46,25 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin) yield* plugin.add(CloudflareWorkersAIPlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
{ provider: provider("cloudflare-workers-ai"), cancel: false }, provider.endpoint = { type: "aisdk", package: "test-provider" }
}),
) )
const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))
const sdk = yield* plugin.trigger( const sdk = yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("cloudflare-workers-ai", "@cf/model", { endpoint: updated.provider.endpoint }), model: model("cloudflare-workers-ai", "@cf/model", { endpoint: provider.endpoint }),
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
}, },
{}, {},
) )
expect(updated.provider.endpoint).toEqual({ expect(provider.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1", url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1",
@@ -63,18 +78,15 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin) yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
{ provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
provider: provider("cloudflare-workers-ai", { }),
endpoint: { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" },
}),
cancel: false,
},
) )
expect(result.provider.endpoint).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
url: "https://proxy.example/v1", url: "https://proxy.example/v1",
@@ -104,7 +116,7 @@ describe("CloudflareWorkersAIPlugin", () => {
), ),
) )
itWithAuth.effect("falls back to auth account metadata when account env is absent", () => itWithAccount.effect("falls back to account metadata when account env is absent", () =>
withEnv( withEnv(
{ {
CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_ACCOUNT_ID: undefined,
@@ -113,30 +125,37 @@ describe("CloudflareWorkersAIPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service const accounts = yield* AccountV2.Service
yield* auth.create({ const catalog = yield* Catalog.Service
serviceID: AuthV2.ServiceID.make("cloudflare-workers-ai"), const events = yield* EventV2.Service
credential: new AuthV2.ApiKeyCredential({ yield* accounts.create({
serviceID: AccountV2.ServiceID.make("cloudflare-workers-ai"),
credential: new AccountV2.ApiKeyCredential({
type: "api", type: "api",
key: "auth-key", key: "account-key",
metadata: { accountId: "auth-acct" }, metadata: { accountId: "account-acct" },
}), }),
active: true,
}) })
yield* plugin.add({ yield* plugin.add({
...AuthPlugin, ...AccountPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
}) })
yield* plugin.add(CloudflareWorkersAIPlugin) yield* plugin.add(CloudflareWorkersAIPlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
{ provider: provider("cloudflare-workers-ai"), cancel: false }, provider.endpoint = { type: "aisdk", package: "test-provider" }
}),
) )
expect(updated.provider.endpoint).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/auth-acct/ai/v1", url: "https://api.cloudflare.com/client/v4/accounts/account-acct/ai/v1",
}) })
}), }),
), ),
@@ -146,18 +165,16 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin) yield* plugin.add(CloudflareWorkersAIPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
{ provider.endpoint = { type: "aisdk", package: "test-provider" }
provider: provider("cloudflare-workers-ai", { provider.options.aisdk.provider.accountId = "configured-acct"
options: { headers: {}, body: {}, aisdk: { provider: { accountId: "configured-acct" }, request: {} } }, }),
}),
cancel: false,
},
) )
expect(result.provider.endpoint).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "test-provider", package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1", url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model } from "./provider-helper" import { fakeSelectorSdk, it, model } from "./provider-helper"
describe("GithubCopilotPlugin", () => { describe("GithubCopilotPlugin", () => {
@@ -145,29 +147,31 @@ describe("GithubCopilotPlugin", () => {
}), }),
) )
it.effect("filters gpt-5-chat-latest before Copilot language selection", () => it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GithubCopilotPlugin) yield* plugin.add(GithubCopilotPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"model.update", yield* load((catalog) => {
{}, catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
{ model: model("github-copilot", "gpt-5-chat-latest"), cancel: false }, catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
) })
expect(result.cancel).toBe(true) expect((yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false)
}), }),
) )
it.effect("does not filter gpt-5-chat-latest for non-Copilot providers", () => it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GithubCopilotPlugin) yield* plugin.add(GithubCopilotPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"model.update", yield* load((catalog) => {
{}, catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
{ model: model("custom-copilot", "gpt-5-chat-latest"), cancel: false }, catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
) })
expect(result.cancel).toBe(false) expect((yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(true)
}), }),
) )
@@ -1,11 +1,15 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth" import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth" import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { it, model, npmLayer, provider, withEnv } from "./provider-helper" import { it, model, npmLayer, withEnv } from "./provider-helper"
const gitlabSDKOptions: Record<string, unknown>[] = [] const gitlabSDKOptions: Record<string, unknown>[] = []
@@ -22,7 +26,15 @@ void mock.module("gitlab-ai-provider", () => ({
isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact", isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact",
})) }))
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer)) const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(npmLayer),
),
)
describe("GitLabPlugin", () => { describe("GitLabPlugin", () => {
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () => it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
@@ -141,7 +153,7 @@ describe("GitLabPlugin", () => {
}), }),
) )
itWithAuth.effect("uses active API auth token over GITLAB_TOKEN", () => itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () =>
withEnv( withEnv(
{ {
GITLAB_TOKEN: "env-token", GITLAB_TOKEN: "env-token",
@@ -150,33 +162,41 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service const accounts = yield* AccountV2.Service
yield* auth.create({ const catalog = yield* Catalog.Service
serviceID: AuthV2.ServiceID.make("gitlab"), const events = yield* EventV2.Service
credential: new AuthV2.ApiKeyCredential({ type: "api", key: "auth-token" }), yield* accounts.create({
active: true, serviceID: AccountV2.ServiceID.make("gitlab"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "account-token" }),
}) })
yield* plugin.add({ yield* plugin.add({
...AuthPlugin, ...AccountPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
}) })
yield* plugin.add(GitLabPlugin) yield* plugin.add(GitLabPlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false }) const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("gitlab", "claude"), model: model("gitlab", "claude"),
package: "gitlab-ai-provider", package: "gitlab-ai-provider",
options: updated.provider.options.aisdk.provider, options: provider.options.aisdk.provider,
}, },
{}, {},
) )
expect(gitlabSDKOptions[0].apiKey).toBe("auth-token") expect(gitlabSDKOptions[0].apiKey).toBe("account-token")
}), }),
), ),
) )
itWithAuth.effect("uses active OAuth access token when no API auth exists", () => itWithAccount.effect("uses active account OAuth access token when no API token exists", () =>
withEnv( withEnv(
{ {
GITLAB_TOKEN: undefined, GITLAB_TOKEN: undefined,
@@ -185,33 +205,41 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
gitlabSDKOptions.length = 0 gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service const accounts = yield* AccountV2.Service
yield* auth.create({ const catalog = yield* Catalog.Service
serviceID: AuthV2.ServiceID.make("gitlab"), const events = yield* EventV2.Service
credential: new AuthV2.OAuthCredential({ yield* accounts.create({
serviceID: AccountV2.ServiceID.make("gitlab"),
credential: new AccountV2.OAuthCredential({
type: "oauth", type: "oauth",
refresh: "refresh-token", refresh: "refresh-token",
access: "oauth-token", access: "account-oauth-token",
expires: 9999999999999, expires: 9999999999999,
}), }),
active: true,
}) })
yield* plugin.add({ yield* plugin.add({
...AuthPlugin, ...AccountPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)), effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
}) })
yield* plugin.add(GitLabPlugin) yield* plugin.add(GitLabPlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false }) const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
model: model("gitlab", "claude"), model: model("gitlab", "claude"),
package: "gitlab-ai-provider", package: "gitlab-ai-provider",
options: updated.provider.options.aisdk.provider, options: provider.options.aisdk.provider,
}, },
{}, {},
) )
expect(gitlabSDKOptions[0].apiKey).toBe("oauth-token") expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token")
}), }),
), ),
) )
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
describe("GoogleVertexAnthropicPlugin", () => { describe("GoogleVertexAnthropicPlugin", () => {
it.effect("resolves legacy project and location env on provider update", () => it.effect("resolves legacy project and location env on provider update", () =>
@@ -18,14 +20,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexAnthropicPlugin) yield* plugin.add(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
{ provider: provider("google-vertex-anthropic"), cancel: false }, provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
}),
) )
expect(result.provider.options.aisdk.provider.project).toBe("cloud-project") const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
expect(result.provider.options.aisdk.provider.location).toBe("cloud-location") expect(provider.options.aisdk.provider.project).toBe("cloud-project")
expect(provider.options.aisdk.provider.location).toBe("cloud-location")
}), }),
), ),
) )
@@ -34,23 +39,19 @@ describe("GoogleVertexAnthropicPlugin", () => {
withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () => withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexAnthropicPlugin) yield* plugin.add(GoogleVertexAnthropicPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
{ provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
provider: provider("google-vertex-anthropic", { provider.options.aisdk.provider.project = "configured-project"
options: { provider.options.aisdk.provider.location = "configured-location"
headers: {}, }),
body: {},
aisdk: { provider: { project: "configured-project", location: "configured-location" }, request: {} },
},
}),
cancel: false,
},
) )
expect(result.provider.options.aisdk.provider.project).toBe("configured-project") const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
expect(result.provider.options.aisdk.provider.location).toBe("configured-location") expect(provider.options.aisdk.provider.project).toBe("configured-project")
expect(provider.options.aisdk.provider.location).toBe("configured-location")
}), }),
), ),
) )
@@ -1,8 +1,10 @@
import { describe, expect, mock } from "bun:test" import { describe, expect, mock } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex" import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
const vertexOptions: Record<string, any>[] = [] const vertexOptions: Record<string, any>[] = []
@@ -43,24 +45,22 @@ describe("GoogleVertexPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin) yield* plugin.add(GoogleVertexPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
{ provider.endpoint = {
provider: provider("google-vertex", { type: "aisdk",
endpoint: { package: "@ai-sdk/openai-compatible",
type: "aisdk", url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
package: "@ai-sdk/openai-compatible", }
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", }),
},
}),
cancel: false,
},
) )
expect(result.provider.options.aisdk.provider.project).toBe("google-cloud-project") const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
expect(result.provider.options.aisdk.provider.location).toBe("google-vertex-location") expect(provider.options.aisdk.provider.project).toBe("google-cloud-project")
expect(result.provider.endpoint).toEqual({ expect(provider.options.aisdk.provider.location).toBe("google-vertex-location")
expect(provider.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location", url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location",
@@ -84,21 +84,19 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () { Effect.gen(function* () {
vertexOptions.length = 0 vertexOptions.length = 0
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin) yield* plugin.add(GoogleVertexPlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
{ provider.endpoint = {
provider: provider("google-vertex", { type: "aisdk",
endpoint: { package: "@ai-sdk/openai-compatible",
type: "aisdk", url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
package: "@ai-sdk/openai-compatible", }
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", }),
},
}),
cancel: false,
},
) )
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
yield* plugin.trigger( yield* plugin.trigger(
"aisdk.sdk", "aisdk.sdk",
{ {
@@ -111,8 +109,8 @@ describe("GoogleVertexPlugin", () => {
{}, {},
) )
expect(updated.provider.options.aisdk.provider.project).toBe("vertex-project") expect(provider.options.aisdk.provider.project).toBe("vertex-project")
expect(updated.provider.endpoint).toEqual({ expect(provider.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4", url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4",
@@ -136,29 +134,24 @@ describe("GoogleVertexPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin) yield* plugin.add(GoogleVertexPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
{ provider.endpoint = {
provider: provider("google-vertex", { type: "aisdk",
endpoint: { package: "@ai-sdk/openai-compatible",
type: "aisdk", url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
package: "@ai-sdk/openai-compatible", }
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}", provider.options.aisdk.provider.project = "config-project"
}, provider.options.aisdk.provider.location = "global"
options: { }),
headers: {},
body: {},
aisdk: { provider: { project: "config-project", location: "global" }, request: {} },
},
}),
cancel: false,
},
) )
expect(result.provider.options.aisdk.provider.project).toBe("config-project") const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
expect(result.provider.options.aisdk.provider.location).toBe("global") expect(provider.options.aisdk.provider.project).toBe("config-project")
expect(result.provider.endpoint).toEqual({ expect(provider.options.aisdk.provider.location).toBe("global")
expect(provider.endpoint).toEqual({
type: "aisdk", type: "aisdk",
package: "@ai-sdk/openai-compatible", package: "@ai-sdk/openai-compatible",
url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global", url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global",
@@ -180,19 +173,18 @@ describe("GoogleVertexPlugin", () => {
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin) yield* plugin.add(GoogleVertexPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) =>
{}, catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
{ provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" }
provider: provider("google-vertex", { provider.options.aisdk.provider.project = "config-project"
options: { headers: {}, body: {}, aisdk: { provider: { project: "config-project" }, request: {} } }, }),
}),
cancel: false,
},
) )
expect(result.provider.options.aisdk.provider.project).toBe("config-project") const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
expect(result.provider.options.aisdk.provider.location).toBe("us-central1") expect(provider.options.aisdk.provider.project).toBe("config-project")
expect(provider.options.aisdk.provider.location).toBe("us-central1")
}), }),
), ),
) )
+32 -1
View File
@@ -2,12 +2,16 @@ import { Npm } from "@opencode-ai/core/npm"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { expect } from "bun:test" import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect" import { Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
export const npmLayer = Layer.succeed( export const npmLayer = Layer.succeed(
Npm.Service, Npm.Service,
@@ -18,7 +22,34 @@ export const npmLayer = Layer.succeed(
}), }),
) )
export const it = testEffect(Layer.mergeAll(PluginV2.defaultLayer, npmLayer)) export const catalogLayer = Layer.succeed(
Catalog.Service,
Catalog.Service.of({
loader: () => Effect.die("unexpected catalog.loader"),
provider: {
get: () => Effect.die("unexpected provider.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.die("unexpected model.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
}),
)
export const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(npmLayer),
),
)
export function provider(providerID: string, options?: Partial<ProviderV2.Info>) { export function provider(providerID: string, options?: Partial<ProviderV2.Info>) {
return new ProviderV2.Info({ return new ProviderV2.Info({
+48 -40
View File
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper" import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("KiloPlugin", () => { describe("KiloPlugin", () => {
@@ -18,73 +20,79 @@ describe("KiloPlugin", () => {
it.effect("applies legacy referer headers only to kilo", () => it.effect("applies legacy referer headers only to kilo", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin) yield* plugin.add(KiloPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const kilo = provider("kilo", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
provider: provider("kilo", { options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, })
}), catalog.provider.update(kilo.id, (draft) => {
cancel: false, draft.endpoint = kilo.endpoint
}, draft.options = kilo.options
) })
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) catalog.provider.update(provider("openrouter").id, () => {})
expect(result.provider.options.headers).toEqual({ })
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect(ignored.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
}), }),
) )
it.effect("uses the exact legacy Kilo header casing and set", () => it.effect("uses the exact legacy Kilo header casing and set", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin) yield* plugin.add(KiloPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("kilo"), cancel: false }) const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("kilo", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" } })
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
expect(result.provider.options.headers).toEqual({ const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo"))
expect(result.options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect(result.provider.options.headers).not.toHaveProperty("http-referer") expect(result.options.headers).not.toHaveProperty("http-referer")
expect(result.provider.options.headers).not.toHaveProperty("x-title") expect(result.options.headers).not.toHaveProperty("x-title")
expect(result.provider.options.headers).not.toHaveProperty("X-Source") expect(result.options.headers).not.toHaveProperty("X-Source")
}), }),
) )
it.effect("uses the legacy provider-id guard instead of endpoint package matching", () => it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin) yield* plugin.add(KiloPlugin)
const matchingID = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const kilo = provider("kilo", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
provider: provider("kilo", { })
endpoint: { type: "aisdk", package: "not-kilo" }, catalog.provider.update(kilo.id, (draft) => {
}), draft.endpoint = kilo.endpoint
cancel: false, })
}, const custom = provider("custom-kilo", {
) endpoint: { type: "aisdk", package: "kilo" },
const matchingPackage = yield* plugin.trigger( })
"provider.update", catalog.provider.update(custom.id, (draft) => {
{}, draft.endpoint = custom.endpoint
{ })
provider: provider("custom-kilo", { })
endpoint: { type: "aisdk", package: "kilo" },
}),
cancel: false,
},
)
expect(matchingID.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect(matchingPackage.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).options.headers).toEqual({})
}), }),
) )
}) })
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper" import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("LLMGatewayPlugin", () => { describe("LLMGatewayPlugin", () => {
@@ -18,46 +20,52 @@ describe("LLMGatewayPlugin", () => {
it.effect("applies legacy referer headers only to enabled llmgateway", () => it.effect("applies legacy referer headers only to enabled llmgateway", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin) yield* plugin.add(LLMGatewayPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const llmgateway = provider("llmgateway", {
{ enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
provider: provider("llmgateway", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" }, options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, })
}), catalog.provider.update(llmgateway.id, (draft) => {
cancel: false, draft.enabled = llmgateway.enabled
}, draft.endpoint = llmgateway.endpoint
) draft.options = llmgateway.options
const ignored = yield* plugin.trigger( })
"provider.update", const openrouter = provider("openrouter", {
{}, enabled: { via: "env", name: "OPENROUTER_API_KEY" },
{ })
provider: provider("openrouter", { catalog.provider.update(openrouter.id, (draft) => {
enabled: { via: "env", name: "OPENROUTER_API_KEY" }, draft.enabled = openrouter.enabled
}), })
cancel: false, })
}, expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({
)
expect(result.provider.options.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-Source": "opencode", "X-Source": "opencode",
}) })
expect(ignored.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
}), }),
) )
it.effect("does not apply legacy headers to a disabled llmgateway provider", () => it.effect("does not apply legacy headers to a disabled llmgateway provider", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin) yield* plugin.add(LLMGatewayPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("llmgateway"), cancel: false }) const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("llmgateway", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" } })
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
expect(result.provider.enabled).toBe(false) expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
expect(result.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({})
}), }),
) )
}) })
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia" import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper" import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("NvidiaPlugin", () => { describe("NvidiaPlugin", () => {
@@ -18,45 +20,48 @@ describe("NvidiaPlugin", () => {
it.effect("applies NVIDIA tracking headers only to nvidia", () => it.effect("applies NVIDIA tracking headers only to nvidia", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin) yield* plugin.add(NvidiaPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const nvidia = provider("nvidia", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
provider: provider("nvidia", { options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, })
}), catalog.provider.update(nvidia.id, (draft) => {
cancel: false, draft.endpoint = nvidia.endpoint
}, draft.options = nvidia.options
) })
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false }) catalog.provider.update(provider("openrouter").id, () => {})
expect(result.provider.options.headers).toEqual({ })
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode", "X-BILLING-INVOKE-ORIGIN": "OpenCode",
}) })
expect(ignored.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
}), }),
) )
it.effect("adds billing origin for custom NVIDIA endpoints", () => it.effect("adds billing origin for custom NVIDIA endpoints", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin) yield* plugin.add(NvidiaPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("nvidia", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
provider: provider("nvidia", { options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
endpoint: { type: "aisdk", package: "test-provider", url: "http://localhost:8000/v1" }, })
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } }, catalog.provider.update(item.id, (draft) => {
}), draft.endpoint = item.endpoint
cancel: false, draft.options = item.options
}, })
) })
expect(result.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode", "X-BILLING-INVOKE-ORIGIN": "OpenCode",
@@ -67,23 +72,25 @@ describe("NvidiaPlugin", () => {
it.effect("preserves an explicit NVIDIA billing origin header", () => it.effect("preserves an explicit NVIDIA billing origin header", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin) yield* plugin.add(NvidiaPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("nvidia", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
provider: provider("nvidia", { options: {
options: { headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, body: {},
body: {}, aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} }, },
}, })
}), catalog.provider.update(item.id, (draft) => {
cancel: false, draft.endpoint = item.endpoint
}, draft.options = item.options
) })
})
expect(result.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin", "X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
@@ -1,9 +1,11 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { fakeSelectorSdk, it, model } from "./provider-helper" import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider } from "./provider-helper"
describe("OpenAIPlugin", () => { describe("OpenAIPlugin", () => {
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
@@ -70,31 +72,37 @@ describe("OpenAIPlugin", () => {
}), }),
) )
it.effect("cancels gpt-5-chat-latest during model updates", () => it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin) yield* plugin.add(OpenAIPlugin)
const normal = yield* plugin.trigger("model.update", {}, { model: model("openai", "gpt-5"), cancel: false }) const load = yield* catalog.loader()
const filtered = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } })
{}, catalog.provider.update(item.id, (draft) => {
{ model: model("openai", "gpt-5-chat-latest"), cancel: false }, draft.endpoint = item.endpoint
) })
expect(normal.cancel).toBe(false) catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
expect(filtered.cancel).toBe(true) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true)
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false)
}), }),
) )
it.effect("does not cancel gpt-5-chat-latest for non-OpenAI providers", () => it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin) yield* plugin.add(OpenAIPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"model.update", yield* load((catalog) => {
{}, const item = provider("custom-openai")
{ model: model("custom-openai", "gpt-5-chat-latest"), cancel: false }, catalog.provider.update(item.id, () => {})
) catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
expect(result.cancel).toBe(false) })
expect((yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(true)
}), }),
) )
}) })
@@ -12,19 +12,23 @@ const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0,
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" })) const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
describe("OpencodePlugin", () => { describe("OpencodePlugin", () => {
it.effect("uses a public key and cancels paid models without credentials", () => it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) const load = yield* catalog.loader()
const paid = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("opencode")
{}, catalog.provider.update(item.id, () => {})
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, const paid = model("opencode", "paid", { cost: cost(1) })
) catalog.model.update(item.id, paid.id, (draft) => {
expect(updated.provider.options.aisdk.provider.apiKey).toBe("public") draft.cost = [...paid.cost]
expect(paid.cancel).toBe(true) })
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
}), }),
), ),
) )
@@ -33,14 +37,19 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) const load = yield* catalog.loader()
const free = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("opencode")
{}, catalog.provider.update(item.id, () => {})
{ model: model("opencode", "free", { cost: cost(0) }), cancel: false }, const free = model("opencode", "free", { cost: cost(0) })
) catalog.model.update(item.id, free.id, (draft) => {
expect(free.cancel).toBe(false) draft.cost = [...free.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -49,14 +58,19 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) const load = yield* catalog.loader()
const outputOnly = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("opencode")
{}, catalog.provider.update(item.id, () => {})
{ model: model("opencode", "output-only", { cost: cost(0, 1) }), cancel: false }, const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
) catalog.model.update(item.id, outputOnly.id, (draft) => {
expect(outputOnly.cancel).toBe(false) draft.cost = [...outputOnly.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -65,15 +79,19 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: "secret" }, () => withEnv({ OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false }) const load = yield* catalog.loader()
const paid = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("opencode")
{}, catalog.provider.update(item.id, () => {})
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, const paid = model("opencode", "paid", { cost: cost(1) })
) catalog.model.update(item.id, paid.id, (draft) => {
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() draft.cost = [...paid.cost]
expect(paid.cancel).toBe(false) })
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -82,19 +100,21 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () => withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
{ provider: provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }), cancel: false }, catalog.provider.update(item.id, (draft) => {
) draft.env = [...item.env]
const paid = yield* plugin.trigger( })
"model.update", const paid = model("opencode", "paid", { cost: cost(1) })
{}, catalog.model.update(item.id, paid.id, (draft) => {
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, draft.cost = [...paid.cost]
) })
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() })
expect(paid.cancel).toBe(false) expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -103,31 +123,30 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("opencode", {
{ options: {
provider: provider("opencode", { headers: {},
options: { body: {},
headers: {}, aisdk: {
body: {}, provider: { apiKey: "configured" },
aisdk: { request: {},
provider: { apiKey: "configured" },
request: {},
},
}, },
}), },
cancel: false, })
}, catalog.provider.update(item.id, (draft) => {
) draft.options = item.options
const paid = yield* plugin.trigger( })
"model.update", const paid = model("opencode", "paid", { cost: cost(1) })
{}, catalog.model.update(item.id, paid.id, (draft) => {
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, draft.cost = [...paid.cost]
) })
expect(updated.provider.options.aisdk.provider.apiKey).toBe("configured") })
expect(paid.cancel).toBe(false) expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("configured")
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -136,19 +155,21 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("opencode", { enabled: { via: "account", service: "opencode" } })
{ provider: provider("opencode", { enabled: { via: "auth", service: "opencode" } }), cancel: false }, catalog.provider.update(item.id, (draft) => {
) draft.enabled = item.enabled
const paid = yield* plugin.trigger( })
"model.update", const paid = model("opencode", "paid", { cost: cost(1) })
{}, catalog.model.update(item.id, paid.id, (draft) => {
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false }, draft.cost = [...paid.cost]
) })
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() })
expect(paid.cancel).toBe(false) expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -157,15 +178,19 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () => withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin) yield* plugin.add(OpencodePlugin)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false }) const load = yield* catalog.loader()
const paid = yield* plugin.trigger( yield* load((catalog) => {
"model.update", const item = provider("openai")
{}, catalog.provider.update(item.id, () => {})
{ model: model("openai", "paid", { cost: cost(1) }), cancel: false }, const paid = model("openai", "paid", { cost: cost(1) })
) catalog.model.update(item.id, paid.id, (draft) => {
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined() draft.cost = [...paid.cost]
expect(paid.cancel).toBe(false) })
})
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
}), }),
), ),
) )
@@ -175,18 +200,21 @@ describe("OpencodePlugin", () => {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.opencode const providerID = ProviderV2.ID.opencode
yield* catalog.provider.update(providerID, () => {}) const load = yield* catalog.loader()
yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => { yield* load((catalog) => {
model.capabilities.input = ["text"] catalog.provider.update(providerID, () => {})
model.capabilities.output = ["text"] catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
model.cost = cost(1, 1) model.capabilities.input = ["text"]
model.time.released = DateTime.makeUnsafe(Date.now()) model.capabilities.output = ["text"]
}) model.cost = [...cost(1, 1)]
yield* catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => { model.time.released = DateTime.makeUnsafe(Date.now())
model.capabilities.input = ["text"] })
model.capabilities.output = ["text"] catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
model.cost = cost(10, 10) model.capabilities.input = ["text"]
model.time.released = DateTime.makeUnsafe(Date.now()) model.capabilities.output = ["text"]
model.cost = [...cost(10, 10)]
model.time.released = DateTime.makeUnsafe(Date.now())
})
}) })
const selected = yield* catalog.model.small(providerID) const selected = yield* catalog.model.small(providerID)
@@ -1,8 +1,11 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter" import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, model, provider } from "./provider-helper" import { expectPluginRegistered, it, model, provider } from "./provider-helper"
describe("OpenRouterPlugin", () => { describe("OpenRouterPlugin", () => {
@@ -18,24 +21,27 @@ describe("OpenRouterPlugin", () => {
it.effect("applies legacy referer headers only to openrouter", () => it.effect("applies legacy referer headers only to openrouter", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin) yield* plugin.add(OpenRouterPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const openrouter = provider("openrouter", {
{ endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
provider: provider("openrouter", { options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, })
}), catalog.provider.update(openrouter.id, (item) => {
cancel: false, item.endpoint = openrouter.endpoint
}, item.options = openrouter.options
) })
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("nvidia"), cancel: false }) catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
expect(result.provider.options.headers).toEqual({ })
expect((yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).options.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
}) })
expect(ignored.provider.options.headers).toEqual({}) expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({})
}), }),
) )
@@ -67,39 +73,43 @@ describe("OpenRouterPlugin", () => {
it.effect("filters OpenRouter's gpt-5 chat alias", () => it.effect("filters OpenRouter's gpt-5 chat alias", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin) yield* plugin.add(OpenRouterPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"model.update", yield* load((catalog) => {
{}, const openrouter = provider("openrouter", { endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" } })
{ model: model("openrouter", "openai/gpt-5-chat"), cancel: false }, catalog.provider.update(openrouter.id, (item) => {
) item.endpoint = openrouter.endpoint
const regular = yield* plugin.trigger( })
"model.update", catalog.provider.update(ProviderV2.ID.openai, () => {})
{}, for (const item of [
{ model: model("openrouter", "openai/gpt-5"), cancel: false }, model("openrouter", "openai/gpt-5-chat"),
) model("openrouter", "openai/gpt-5"),
const ignored = yield* plugin.trigger( model("openai", "openai/gpt-5-chat"),
"model.update", ]) {
{}, catalog.model.update(item.providerID, item.id, () => {})
{ model: model("openai", "openai/gpt-5-chat"), cancel: false }, }
) })
expect(result.cancel).toBe(true) expect((yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(false)
expect(regular.cancel).toBe(false) expect((yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled).toBe(true)
expect(ignored.cancel).toBe(false) expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(true)
}), }),
) )
it.effect("does not filter gpt-5-chat-latest for non-OpenRouter providers", () => it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin) yield* plugin.add(OpenRouterPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"model.update", yield* load((catalog) => {
{}, catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
{ model: model("custom-openrouter", "gpt-5-chat-latest"), cancel: false }, catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
) })
expect(result.cancel).toBe(false) expect(
(yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
).toBe(true)
}), }),
) )
}) })
@@ -1,25 +1,29 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel" import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider } from "./provider-helper" import { it, model, provider } from "./provider-helper"
describe("VercelPlugin", () => { describe("VercelPlugin", () => {
it.effect("applies legacy lower-case referer headers", () => it.effect("applies legacy lower-case referer headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin) yield* plugin.add(VercelPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("vercel", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/vercel" },
provider: provider("vercel", { options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } }, })
}), catalog.provider.update(item.id, (draft) => {
cancel: false, draft.endpoint = item.endpoint
}, draft.options = item.options
) })
expect(result.provider.options.headers).toEqual({ })
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).toEqual({
Existing: "1", Existing: "1",
"http-referer": "https://opencode.ai/", "http-referer": "https://opencode.ai/",
"x-title": "opencode", "x-title": "opencode",
@@ -30,10 +34,17 @@ describe("VercelPlugin", () => {
it.effect("does not add legacy upper-case referer headers", () => it.effect("does not add legacy upper-case referer headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin) yield* plugin.add(VercelPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("vercel"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.headers).not.toHaveProperty("HTTP-Referer") yield* load((catalog) => {
expect(result.provider.options.headers).not.toHaveProperty("X-Title") const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } })
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty("HTTP-Referer")
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty("X-Title")
}), }),
) )
@@ -54,9 +65,11 @@ describe("VercelPlugin", () => {
it.effect("ignores non-Vercel providers", () => it.effect("ignores non-Vercel providers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin) yield* plugin.add(VercelPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("gateway"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.headers).toEqual({}) yield* load((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).options.headers).toEqual({})
}), }),
) )
}) })
@@ -1,8 +1,10 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux" import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper" import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("ZenmuxPlugin", () => { describe("ZenmuxPlugin", () => {
@@ -18,30 +20,39 @@ describe("ZenmuxPlugin", () => {
it.effect("applies the exact legacy Zenmux headers", () => it.effect("applies the exact legacy Zenmux headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin) yield* plugin.add(ZenmuxPlugin)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("zenmux"), cancel: false }) const load = yield* catalog.loader()
expect(result.provider.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" }) yield* load((catalog) => {
expect(Object.keys(result.provider.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"]) const item = provider("zenmux", { endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" } })
expect(result.cancel).toBe(false) catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
const result = yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))
expect(result.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
expect(Object.keys(result.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
}), }),
) )
it.effect("merges legacy Zenmux headers with existing headers", () => it.effect("merges legacy Zenmux headers with existing headers", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin) yield* plugin.add(ZenmuxPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("zenmux", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
provider: provider("zenmux", { options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } }, })
}), catalog.provider.update(item.id, (draft) => {
cancel: false, draft.endpoint = item.endpoint
}, draft.options = item.options
) })
})
expect(result.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({
Existing: "value", Existing: "value",
"HTTP-Referer": "https://opencode.ai/", "HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode", "X-Title": "opencode",
@@ -52,23 +63,25 @@ describe("ZenmuxPlugin", () => {
it.effect("lets configured Zenmux legacy headers override defaults", () => it.effect("lets configured Zenmux legacy headers override defaults", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin) yield* plugin.add(ZenmuxPlugin)
const result = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("zenmux", {
{ endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
provider: provider("zenmux", { options: {
options: { headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, body: {},
body: {}, aisdk: { provider: {}, request: {} },
aisdk: { provider: {}, request: {} }, },
}, })
}), catalog.provider.update(item.id, (draft) => {
cancel: false, draft.endpoint = item.endpoint
}, draft.options = item.options
) })
})
expect(result.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({
"HTTP-Referer": "https://example.com/", "HTTP-Referer": "https://example.com/",
"X-Title": "custom-title", "X-Title": "custom-title",
}) })
@@ -78,23 +91,23 @@ describe("ZenmuxPlugin", () => {
it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () => it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin) yield* plugin.add(ZenmuxPlugin)
const ignored = yield* plugin.trigger( const load = yield* catalog.loader()
"provider.update", yield* load((catalog) => {
{}, const item = provider("openrouter", {
{ options: {
provider: provider("openrouter", { headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
options: { body: {},
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" }, aisdk: { provider: {}, request: {} },
body: {}, },
aisdk: { provider: {}, request: {} }, })
}, catalog.provider.update(item.id, (draft) => {
}), draft.options = item.options
cancel: false, })
}, })
)
expect(ignored.provider.options.headers).toEqual({ expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({
"HTTP-Referer": "https://example.com/", "HTTP-Referer": "https://example.com/",
"X-Title": "custom-title", "X-Title": "custom-title",
}) })
+11 -12
View File
@@ -1,18 +1,16 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect, Layer, Option } from "effect" import { Effect, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { effectCmd } from "../../effect-cmd" import { effectCmd } from "../../effect-cmd"
const Runtime = Layer.mergeAll(LocationServiceMap.layer)
export const V2Command = effectCmd({ export const V2Command = effectCmd({
command: "v2", command: "v2",
describe: "debug v2 catalog and built-in plugins", describe: "debug v2 catalog and built-in plugins",
instance: false, instance: false,
handler: Effect.fn("Cli.debug.v2")( handler: () =>
function* () { Effect.gen(function* () {
yield* PluginBoot.Service.use((service) => service.wait()) yield* PluginBoot.Service.use((service) => service.wait())
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id)) const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id))
@@ -35,12 +33,13 @@ export const V2Command = effectCmd({
), ),
} }
process.stdout.write(JSON.stringify(result, null, 2) + EOL) process.stdout.write(JSON.stringify(result, null, 2) + EOL)
}, }).pipe(
Effect.provide( Effect.withSpan("Cli.debug.v2"),
LocationServiceMap.get({ Effect.provide(
directory: process.cwd(), LocationServiceMap.get({
}), directory: process.cwd(),
}),
),
Effect.provide(LocationServiceMap.layer),
), ),
Effect.provide(Runtime),
),
}) })
+2 -2
View File
@@ -458,7 +458,7 @@ export const RunCommand = effectCmd({
const name = title() const name = title()
const result = await sdk.session.create({ const result = await sdk.session.create({
title: name, title: name,
permission: rules, permission: [...rules],
}) })
const id = result.data?.id const id = result.data?.id
if (!id) { if (!id) {
@@ -501,7 +501,7 @@ export const RunCommand = effectCmd({
variant: input.variant, variant: input.variant,
} }
: undefined, : undefined,
permission: rules, permission: [...rules],
}) })
const id = result.data?.id const id = result.data?.id
if (!id) { if (!id) {
+1 -1
View File
@@ -81,7 +81,7 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provideMerge(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(SyncEvent.defaultLayer), Layer.provide(SyncEvent.defaultLayer),
Layer.provide(ProjectBus.defaultLayer), Layer.provide(ProjectBus.defaultLayer),
) )
+1 -15
View File
@@ -1,15 +1 @@
import { Wildcard } from "@/util/wildcard" export { evaluate } from "@opencode-ai/core/permission"
type Rule = {
permission: string
pattern: string
action: "allow" | "deny" | "ask"
}
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
const rules = rulesets.flat()
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
+11 -20
View File
@@ -8,15 +8,15 @@ import { PermissionTable } from "@/session/session.sql"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Wildcard } from "@/util/wildcard" import { Wildcard } from "@opencode-ai/core/util/wildcard"
import { Deferred, Effect, Layer, Schema, Context } from "effect" import { Deferred, Effect, Layer, Schema, Context } from "effect"
import os from "os" import os from "os"
import { evaluate as evalRule } from "./evaluate" import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionID } from "./schema" import { PermissionID } from "./schema"
const log = Log.create({ service: "permission" }) const log = Log.create({ service: "permission" })
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" }) export const Action = PermissionV2.Action.annotate({ identifier: "PermissionAction" })
export type Action = Schema.Schema.Type<typeof Action> export type Action = Schema.Schema.Type<typeof Action>
export const Rule = Schema.Struct({ export const Rule = Schema.Struct({
@@ -26,7 +26,7 @@ export const Rule = Schema.Struct({
}).annotate({ identifier: "PermissionRule" }) }).annotate({ identifier: "PermissionRule" })
export type Rule = Schema.Schema.Type<typeof Rule> export type Rule = Schema.Schema.Type<typeof Rule>
export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionRuleset" }) export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
export type Ruleset = Schema.Schema.Type<typeof Ruleset> export type Ruleset = Schema.Schema.Type<typeof Ruleset>
export class Request extends Schema.Class<Request>("PermissionRequest")({ export class Request extends Schema.Class<Request>("PermissionRequest")({
@@ -122,11 +122,11 @@ interface PendingEntry {
interface State { interface State {
pending: Map<PermissionID, PendingEntry> pending: Map<PermissionID, PendingEntry>
approved: Ruleset approved: Rule[]
} }
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return evalRule(permission, pattern, ...rulesets) return PermissionV2.evaluate(permission, pattern, ...rulesets)
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
@@ -142,7 +142,7 @@ export const layer = Layer.effect(
) )
const state = { const state = {
pending: new Map<PermissionID, PendingEntry>(), pending: new Map<PermissionID, PendingEntry>(),
approved: row?.data ?? [], approved: [...(row?.data ?? [])],
} }
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
@@ -271,7 +271,7 @@ function expand(pattern: string): string {
} }
export function fromConfig(permission: ConfigPermission.Info) { export function fromConfig(permission: ConfigPermission.Info) {
const ruleset: Ruleset = [] const ruleset: Rule[] = []
for (const [key, value] of Object.entries(permission)) { for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") { if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" }) ruleset.push({ permission: key, action: value, pattern: "*" })
@@ -284,21 +284,12 @@ export function fromConfig(permission: ConfigPermission.Info) {
return ruleset return ruleset
} }
export function merge(...rulesets: Ruleset[]): Ruleset { export function merge(...rulesets: Ruleset[]): Rule[] {
return rulesets.flat() return [...PermissionV2.merge(...rulesets)]
} }
const EDIT_TOOLS = ["edit", "write", "apply_patch"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> { export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
const result = new Set<string>() return PermissionV2.disabled(tools, ruleset)
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
if (!rule) continue
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
}
return result
} }
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
@@ -159,9 +159,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
if (body.trim().length === 0) return yield* create({}) if (body.trim().length === 0) return yield* create({})
const json = yield* tryParseJson(body) const json = yield* tryParseJson(body)
const payload = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe( const decoded = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})), Effect.mapError(() => new HttpApiError.BadRequest({})),
) )
const payload = decoded
? {
...decoded,
permission: decoded.permission ? [...decoded.permission] : undefined,
}
: decoded
return yield* create({ payload }) return yield* create({ payload })
}) })
+1 -1
View File
@@ -1216,7 +1216,7 @@ export const layer = Layer.effect(
const message = yield* createUserMessage(input) const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID) yield* sessions.touch(input.sessionID)
const permissions: Permission.Ruleset = [] const permissions: Permission.Rule[] = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) { for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
} }
+3 -3
View File
@@ -101,7 +101,7 @@ export function fromRow(row: SessionRow): Info {
}, },
share, share,
revert, revert,
permission: row.permission ?? undefined, permission: row.permission ? [...row.permission] : undefined,
time: { time: {
created: row.time_created, created: row.time_created,
updated: row.time_updated, updated: row.time_updated,
@@ -542,7 +542,7 @@ export const layer: Layer.Layer<
title: input.title ?? createDefaultTitle(!!input.parentID), title: input.title ?? createDefaultTitle(!!input.parentID),
agent: input.agent, agent: input.agent,
model: input.model, model: input.model,
permission: input.permission, permission: input.permission ? [...input.permission] : undefined,
cost: 0, cost: 0,
tokens: EmptyTokens, tokens: EmptyTokens,
time: { time: {
@@ -734,7 +734,7 @@ export const layer: Layer.Layer<
sessionID: SessionID sessionID: SessionID
permission: Permission.Ruleset permission: Permission.Ruleset
}) { }) {
yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } }) yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } })
}) })
const setRevert = Effect.fn("Session.setRevert")(function* (input: { const setRevert = Effect.fn("Session.setRevert")(function* (input: {
+1 -1
View File
@@ -4,7 +4,7 @@ import { SyncEvent } from "@/sync"
import { Effect, Layer, Scope, Context } from "effect" import { Effect, Layer, Scope, Context } from "effect"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import * as ShareNext from "./share-next" import { ShareNext } from "./share-next"
export interface Interface { export interface Interface {
readonly create: (input?: Session.CreateInput) => Effect.Effect<Session.Info> readonly create: (input?: Session.CreateInput) => Effect.Effect<Session.Info>
@@ -77,7 +77,7 @@ const publishPartUpdated = (partID: ReturnType<typeof PartID.ascending>) => {
const subscribeAllCallback = (handler: (event: BusEvent) => void) => const subscribeAllCallback = (handler: (event: BusEvent) => void) =>
Effect.acquireRelease(inApp(Bus.Service.use((svc) => svc.subscribeAllCallback(handler))), (dispose) => Effect.acquireRelease(inApp(Bus.Service.use((svc) => svc.subscribeAllCallback(handler))), (dispose) =>
Effect.sync(dispose), Effect.sync(() => dispose()),
) )
const openEventStream = (directory: string) => const openEventStream = (directory: string) =>
+1 -1
View File
@@ -3529,7 +3529,7 @@ export type ProviderV2Info = {
name: string name: string
} }
| { | {
via: "auth" via: "account"
service: string service: string
} }
| { | {
+121
View File
@@ -0,0 +1,121 @@
# V2 Core Instructions
These notes describe how to work on `packages/core` during the v2 port.
## Direction
Move behavior out of large application services and into plugins. Core services should become small, typed containers that own state, expose simple operations, and trigger hooks where policy or integration-specific logic belongs.
The target shape is:
- `packages/core` contains domain schemas, typed errors, state containers, events, and plugin hook contracts.
- Plugins implement provider-specific, config-specific, auth-specific, model-discovery, and generation behavior.
- Services are hot-reloadable by design: updates are granular, observable, and do not require tearing down the whole process.
- `packages/opencode` becomes thinner over time: UI, server routes, CLI, storage glue, and legacy compatibility should call the core services instead of owning domain logic directly.
## Service Shape
Core services should look like `Catalog`, `AccountV2`, and `AgentV2`:
- define schemas and branded ids at the top of the module
- define typed `Schema.TaggedErrorClass` errors for expected failures
- define an `Interface` with small operations
- expose a `Context.Service`
- implement `layer` with private in-memory state
- expose `defaultLayer` with explicit dependencies
- self-export with `export * as Name from "./file"`
Prefer a dumb container API:
- `get`, `all`, `available`, `default`, `update`, `remove`, `activate`, or other small domain verbs
- `update(id, draft => ...)` for registration and mutation
- hook calls before committing mutations when plugins need to enrich, cancel, or validate changes
- events after committing mutations when other services or frontends need to react
Avoid putting application policy directly in core services unless it is a domain invariant. For example, resolving model endpoint inheritance is catalog-owned; deciding which providers to register is plugin-owned.
## Plugin Hooks
Plugins are the extension boundary for v2. Add hooks to `PluginV2.HookSpec` when logic should be provided by integrations instead of the container itself.
Hook conventions:
- hooks receive immutable input plus mutable output
- mutable object outputs are exposed as Immer drafts
- include `cancel: boolean` when plugins can prevent a mutation
- trigger hooks sequentially so ordering remains deterministic
- keep hook names domain-oriented, like `provider.update`, `model.update`, `account.activate`, `agent.generate`
- keep hook payloads small and typed with core schemas
Use hooks for:
- registering providers and models
- applying env/account/config-derived enablement
- transforming SDK/provider options
- implementing generated behavior such as agent generation
- choosing defaults when the choice is policy rather than state
Do not use hooks as a dumping ground for transport concerns, UI behavior, or compatibility shims.
## Plugin Boot
Built-in core plugins are registered by `packages/core/src/plugin/boot.ts`.
When a new core service is intended to be available to plugins:
- add the service to the boot layer dependency type
- yield the service inside the layer
- provide it to each plugin effect in `add`
- add its default layer to `PluginBoot.defaultLayer` only when that does not create a cycle
Keep boot as composition only. It should not contain provider, account, agent, or model policy itself.
## Boundaries
Core should not import from `packages/opencode`. If a type or concept is needed by core, move or remodel the domain shape in core first.
Avoid moving legacy services over wholesale. Port the domain shape and the container API, then leave specific behavior behind hooks for plugins to implement.
When porting an opencode service:
- identify the state it owns
- identify the operations callers actually need
- identify which branches are policy or integration behavior
- model state and operations in `packages/core`
- add hooks for the policy/integration branches
- keep old package code working until callers can migrate incrementally
## Schemas And Types
Use Effect schemas as the public contract:
- branded schemas for ids
- `Schema.Class` or `Schema.Struct` for domain data
- `Schema.TaggedErrorClass` for expected errors
- existing core helpers like `DeepMutable`, `withStatics`, and integer schemas where appropriate
Prefer `Info` objects as the stored domain records. Add static `empty(...)` constructors when update APIs need to create records on first mutation.
Keep schemas stable and explicit. Do not rely on opencode config shapes as core domain shapes unless the config shape is actually the domain model.
## State And Events
Keep state private to the service layer. Use immutable replacement or Effect refs when persistence/concurrency requires it.
Publish events for committed domain changes, not for attempted mutations. Event names should describe domain facts, for example `catalog.model.updated`.
The v2 goal is granular reconfiguration. A model update should let dependents react to that model update; it should not require global reloads.
## Style
Follow the local core style:
- `Effect.gen(function* () { ... })` for composition
- `Effect.fn("Domain.method")` for public service methods
- `Effect.fnUntraced` for small internal mutation helpers
- `yield* new ErrorClass(...)` for typed failures
- minimal helpers unless they name a real concept
- no `any` unless an existing plugin boundary requires it
- no compatibility code without a concrete persisted or external-consumer need
Prefer the smallest correct port. The goal is to make services easier to replace and reason about, not to recreate the old architecture in a new package.
+48 -8
View File
@@ -232,7 +232,7 @@ export interface Interface {
} }
``` ```
`ProviderV2.Info.enabled` is stored provider state. Provider plugins set this field after checking env, auth, config, or provider-specific availability. `ProviderV2.Info.enabled` is stored provider state. Provider plugins set this field after checking env, account, config, or provider-specific availability.
`ProviderV2.Endpoint` includes `{ type: "unknown" }`. `CatalogV2.model.get()` and `CatalogV2.model.all()` resolve `unknown` endpoints from the provider before returning models. `ProviderV2.Endpoint` includes `{ type: "unknown" }`. `CatalogV2.model.get()` and `CatalogV2.model.all()` resolve `unknown` endpoints from the provider before returning models.
@@ -256,6 +256,46 @@ const available = provider.enabled && model.status !== "deprecated"
## Plugin Interface ## Plugin Interface
```ts ```ts
type HookSpec = {
"account.update": {
input: {
id: AccountV2.ID
serviceID: AccountV2.ServiceID
}
output: {
description: string
credential: AccountV2.Credential
cancel: boolean
}
}
"account.remove": {
input: {
account: AccountV2.Info
}
output: {
cancel: boolean
}
}
"account.activate": {
input: {}
output: {
from?: AccountV2.ID
to: AccountV2.ID
cancel: boolean
}
}
"account.activated": {
input: {
from?: AccountV2.ID
to: AccountV2.ID
}
output: {}
}
}
export type Definition<R = never> = Effect.Effect< export type Definition<R = never> = Effect.Effect<
{ {
readonly order: number readonly order: number
@@ -280,7 +320,7 @@ export interface Interface {
export const Order = { export const Order = {
modelsDev: 0, modelsDev: 0,
env: 10, env: 10,
auth: 20, account: 20,
provider: 30, provider: 30,
config: 40, config: 40,
discovery: 50, discovery: 50,
@@ -294,21 +334,21 @@ export const ModelsDevPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.S
export const EnvPlugin: PluginV2.Definition<ProviderV2.Service | Env.Service> export const EnvPlugin: PluginV2.Definition<ProviderV2.Service | Env.Service>
export const AuthPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service> export const AccountPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service>
export const ConfigPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.Service | Config.Service> export const ConfigPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.Service | Config.Service>
export const AnthropicPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service> export const AnthropicPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service>
export const OpenRouterPlugin: PluginV2.Definition<ProviderV2.Service> export const OpenRouterPlugin: PluginV2.Definition<ProviderV2.Service>
export const AmazonBedrockPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service | Env.Service> export const AmazonBedrockPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service | Env.Service>
export const GoogleVertexPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service | Env.Service> export const GoogleVertexPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service | Env.Service>
export const GitLabPlugin: PluginV2.Definition<ProviderV2.Service | AuthV2.Service | Env.Service> export const GitLabPlugin: PluginV2.Definition<ProviderV2.Service | AccountV2.Service | Env.Service>
export const GitLabDiscoveryPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.Service | AuthV2.Service> export const GitLabDiscoveryPlugin: PluginV2.Definition<ProviderV2.Service | ModelV2.Service | AccountV2.Service>
``` ```
## Plugin Hooks ## Plugin Hooks