Rename v2 auth service to account (#28260)
This commit is contained in:
@@ -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"
|
||||||
@@ -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))
|
||||||
@@ -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
@@ -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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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: [],
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
}
|
}
|
||||||
|
"account.switched": {
|
||||||
|
input: {
|
||||||
|
serviceID: import("./account").AccountV2.ServiceID
|
||||||
|
from?: import("./account").AccountV2.ID
|
||||||
|
to?: import("./account").AccountV2.ID
|
||||||
}
|
}
|
||||||
"model.update": {
|
output: {}
|
||||||
input: {}
|
|
||||||
output: {
|
|
||||||
model: ModelV2.Info
|
|
||||||
cancel: boolean
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"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
|
||||||
|
|||||||
@@ -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* () {}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
@@ -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,14 +25,15 @@ 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 events = yield* EventV2.Service
|
||||||
const done = yield* Deferred.make<void>()
|
const done = yield* Deferred.make<void>()
|
||||||
|
|
||||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||||
@@ -36,15 +41,18 @@ export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Servi
|
|||||||
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(AccountV2.Service, accounts),
|
||||||
Effect.provideService(Npm.Service, npm),
|
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)
|
||||||
}
|
}
|
||||||
@@ -61,11 +69,13 @@ export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Servi
|
|||||||
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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ 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
|
||||||
|
evt.provider.update(item.provider.id, (provider) => {
|
||||||
|
provider.enabled = {
|
||||||
via: "env",
|
via: "env",
|
||||||
name: key,
|
name: key,
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,9 +55,15 @@ 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 scope = yield* Scope.Scope
|
||||||
|
const load = yield* catalog.loader()
|
||||||
|
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
|
||||||
|
const data = yield* modelsDev.get()
|
||||||
|
yield* load((catalog) => {
|
||||||
|
for (const item of Object.values(data)) {
|
||||||
const providerID = ProviderV2.ID.make(item.id)
|
const providerID = ProviderV2.ID.make(item.id)
|
||||||
yield* catalog.provider.update(providerID, (provider) => {
|
catalog.provider.update(providerID, (provider) => {
|
||||||
provider.name = item.name
|
provider.name = item.name
|
||||||
provider.env = [...item.env]
|
provider.env = [...item.env]
|
||||||
provider.endpoint = item.npm
|
provider.endpoint = item.npm
|
||||||
@@ -72,8 +79,7 @@ export const ModelsDevPlugin = PluginV2.define({
|
|||||||
|
|
||||||
for (const model of Object.values(item.models)) {
|
for (const model of Object.values(item.models)) {
|
||||||
const modelID = ModelV2.ID.make(model.id)
|
const modelID = ModelV2.ID.make(model.id)
|
||||||
yield* catalog.model
|
catalog.model.update(providerID, modelID, (draft) => {
|
||||||
.update(providerID, modelID, (draft) => {
|
|
||||||
draft.name = model.name
|
draft.name = model.name
|
||||||
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
|
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
|
||||||
draft.endpoint = model.provider?.npm
|
draft.endpoint = model.provider?.npm
|
||||||
@@ -101,8 +107,14 @@ export const ModelsDevPlugin = PluginV2.define({
|
|||||||
output: model.limit.output,
|
output: model.limit.output,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
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
|
||||||
|
evt.provider.update(item.provider.id, (provider) => {
|
||||||
|
if (provider.endpoint.type !== "aisdk") return
|
||||||
|
if (typeof provider.options.aisdk.provider.endpoint !== "string") return
|
||||||
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
|
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
|
||||||
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
||||||
evt.provider.endpoint.url = evt.provider.options.aisdk.provider.endpoint
|
provider.endpoint.url = provider.options.aisdk.provider.endpoint
|
||||||
delete evt.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
|
||||||
|
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"
|
"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
|
||||||
|
|||||||
@@ -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
|
||||||
|
if (item.provider.endpoint.package !== "@ai-sdk/azure") continue
|
||||||
|
const configured = item.provider.options.aisdk.provider.resourceName
|
||||||
const resourceName =
|
const resourceName =
|
||||||
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
|
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
|
||||||
if (resourceName) evt.provider.options.aisdk.provider.resourceName = resourceName
|
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)
|
||||||
|
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
|
||||||
|
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||||
// so hide it only for Copilot rather than for every provider catalog.
|
// so hide it only for Copilot rather than for every provider catalog.
|
||||||
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
|
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 (provider.endpoint.type === "aisdk" && provider.endpoint.url) {
|
||||||
|
provider.endpoint.url = replaceVertexVars(provider.endpoint.url, project, location)
|
||||||
}
|
}
|
||||||
if (
|
if (provider.endpoint.type === "aisdk" && provider.endpoint.package.includes("@ai-sdk/openai-compatible")) {
|
||||||
evt.provider.endpoint.type === "aisdk" &&
|
provider.options.aisdk.provider.fetch = authFetch(provider.options.aisdk.provider.fetch)
|
||||||
evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible")
|
}
|
||||||
) {
|
})
|
||||||
evt.provider.options.aisdk.provider.fetch = authFetch(evt.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) {
|
||||||
|
if (item.provider.endpoint.type !== "aisdk") continue
|
||||||
|
if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue
|
||||||
const project =
|
const project =
|
||||||
evt.provider.options.aisdk.provider.project ??
|
item.provider.options.aisdk.provider.project ??
|
||||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||||
process.env.GCP_PROJECT ??
|
process.env.GCP_PROJECT ??
|
||||||
process.env.GCLOUD_PROJECT
|
process.env.GCLOUD_PROJECT
|
||||||
const location =
|
const location =
|
||||||
evt.provider.options.aisdk.provider.location ??
|
item.provider.options.aisdk.provider.location ??
|
||||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||||
process.env.VERTEX_LOCATION ??
|
process.env.VERTEX_LOCATION ??
|
||||||
"global"
|
"global"
|
||||||
if (project) evt.provider.options.aisdk.provider.project = project
|
evt.provider.update(item.provider.id, (provider) => {
|
||||||
evt.provider.options.aisdk.provider.location = location
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
})
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
})
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (item.provider.endpoint.type !== "aisdk") continue
|
||||||
|
if (item.provider.endpoint.package !== "@ai-sdk/openai") continue
|
||||||
|
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
|
||||||
|
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||||
// chat-completions-only model, so remove it only from OpenAI's catalog.
|
// chat-completions-only model, so hide it only from OpenAI's catalog.
|
||||||
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
|
model.enabled = false
|
||||||
|
})
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
})
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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",
|
|
||||||
url: "https://default.example.com",
|
|
||||||
}
|
|
||||||
provider.options.aisdk.provider.baseURL = "https://override.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",
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
yield* catalog.model.update(providerID, modelID, (model) => {
|
catalog.model.update(providerID, modelID, (model) => {
|
||||||
model.endpoint = {
|
model.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://model.example.com" }
|
||||||
type: "aisdk",
|
|
||||||
package: "@ai-sdk/openai-compatible",
|
|
||||||
url: "https://model.example.com",
|
|
||||||
}
|
|
||||||
model.options.aisdk.provider.baseURL = "https://override.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) => {
|
||||||
|
catalog.provider.update(providerID, (provider) => {
|
||||||
provider.options.headers.provider = "provider"
|
provider.options.headers.provider = "provider"
|
||||||
provider.options.headers.shared = "provider"
|
provider.options.headers.shared = "provider"
|
||||||
provider.options.body.provider = true
|
provider.options.body.provider = true
|
||||||
provider.options.aisdk.provider.provider = true
|
provider.options.aisdk.provider.provider = true
|
||||||
})
|
})
|
||||||
yield* catalog.model.update(providerID, modelID, (model) => {
|
catalog.model.update(providerID, modelID, (model) => {
|
||||||
model.options.headers.model = "model"
|
model.options.headers.model = "model"
|
||||||
model.options.headers.shared = "model"
|
model.options.headers.shared = "model"
|
||||||
model.options.body.model = true
|
model.options.body.model = true
|
||||||
model.options.aisdk.provider.model = true
|
model.options.aisdk.provider.model = true
|
||||||
model.options.aisdk.request.request = 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, () => {})
|
||||||
|
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
|
||||||
model.capabilities.input = ["text"]
|
model.capabilities.input = ["text"]
|
||||||
model.capabilities.output = ["text"]
|
model.capabilities.output = ["text"]
|
||||||
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
|
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
|
||||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||||
})
|
})
|
||||||
yield* catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
|
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
|
||||||
model.capabilities.input = ["text"]
|
model.capabilities.input = ["text"]
|
||||||
model.capabilities.output = ["text"]
|
model.capabilities.output = ["text"]
|
||||||
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
|
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
|
||||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
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")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: {} },
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(bedrock.id, (item) => {
|
||||||
},
|
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: {} } },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(item.id, (draft) => {
|
||||||
},
|
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(
|
})
|
||||||
"https://cognitive.cognitiveservices.azure.com/openai",
|
const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
|
||||||
)
|
expect(result.endpoint).toEqual({
|
||||||
expect(result.provider.options.aisdk.provider.resourceName).toBeUndefined()
|
type: "aisdk",
|
||||||
|
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: {} } },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(azure.id, (item) => {
|
||||||
},
|
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: {} } },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(azure.id, (item) => {
|
||||||
},
|
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: {} } },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(azure.id, (item) => {
|
||||||
},
|
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", {
|
|
||||||
endpoint: {
|
|
||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
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", {
|
|
||||||
endpoint: {
|
|
||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
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", {
|
|
||||||
endpoint: {
|
|
||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||||
},
|
}
|
||||||
options: {
|
provider.options.aisdk.provider.project = "config-project"
|
||||||
headers: {},
|
provider.options.aisdk.provider.location = "global"
|
||||||
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")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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: {} } },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(kilo.id, (draft) => {
|
||||||
},
|
draft.endpoint = kilo.endpoint
|
||||||
)
|
draft.options = kilo.options
|
||||||
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
|
})
|
||||||
expect(result.provider.options.headers).toEqual({
|
catalog.provider.update(provider("openrouter").id, () => {})
|
||||||
|
})
|
||||||
|
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", {
|
||||||
)
|
|
||||||
const matchingPackage = yield* plugin.trigger(
|
|
||||||
"provider.update",
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
provider: provider("custom-kilo", {
|
|
||||||
endpoint: { type: "aisdk", package: "kilo" },
|
endpoint: { type: "aisdk", package: "kilo" },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(custom.id, (draft) => {
|
||||||
},
|
draft.endpoint = custom.endpoint
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
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", {
|
||||||
{
|
|
||||||
provider: provider("llmgateway", {
|
|
||||||
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
|
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
|
||||||
|
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(llmgateway.id, (draft) => {
|
||||||
},
|
draft.enabled = llmgateway.enabled
|
||||||
)
|
draft.endpoint = llmgateway.endpoint
|
||||||
const ignored = yield* plugin.trigger(
|
draft.options = llmgateway.options
|
||||||
"provider.update",
|
})
|
||||||
{},
|
const openrouter = provider("openrouter", {
|
||||||
{
|
|
||||||
provider: provider("openrouter", {
|
|
||||||
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
|
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(openrouter.id, (draft) => {
|
||||||
},
|
draft.enabled = openrouter.enabled
|
||||||
)
|
})
|
||||||
expect(result.provider.options.headers).toEqual({
|
})
|
||||||
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).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: {} } },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(nvidia.id, (draft) => {
|
||||||
},
|
draft.endpoint = nvidia.endpoint
|
||||||
)
|
draft.options = nvidia.options
|
||||||
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
|
})
|
||||||
expect(result.provider.options.headers).toEqual({
|
catalog.provider.update(provider("openrouter").id, () => {})
|
||||||
|
})
|
||||||
|
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", {
|
|
||||||
endpoint: { type: "aisdk", package: "test-provider", url: "http://localhost:8000/v1" },
|
|
||||||
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
|
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(item.id, (draft) => {
|
||||||
},
|
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": "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: {} },
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(item.id, (draft) => {
|
||||||
},
|
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,12 +123,11 @@ 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", {
|
||||||
{
|
|
||||||
provider: provider("opencode", {
|
|
||||||
options: {
|
options: {
|
||||||
headers: {},
|
headers: {},
|
||||||
body: {},
|
body: {},
|
||||||
@@ -117,17 +136,17 @@ describe("OpencodePlugin", () => {
|
|||||||
request: {},
|
request: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(item.id, (draft) => {
|
||||||
},
|
draft.options = item.options
|
||||||
)
|
})
|
||||||
const paid = yield* plugin.trigger(
|
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||||
"model.update",
|
catalog.model.update(item.id, paid.id, (draft) => {
|
||||||
{},
|
draft.cost = [...paid.cost]
|
||||||
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
|
})
|
||||||
)
|
})
|
||||||
expect(updated.provider.options.aisdk.provider.apiKey).toBe("configured")
|
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("configured")
|
||||||
expect(paid.cancel).toBe(false)
|
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,19 +200,22 @@ 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) => {
|
||||||
|
catalog.provider.update(providerID, () => {})
|
||||||
|
catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
|
||||||
model.capabilities.input = ["text"]
|
model.capabilities.input = ["text"]
|
||||||
model.capabilities.output = ["text"]
|
model.capabilities.output = ["text"]
|
||||||
model.cost = cost(1, 1)
|
model.cost = [...cost(1, 1)]
|
||||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||||
})
|
})
|
||||||
yield* catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
|
catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
|
||||||
model.capabilities.input = ["text"]
|
model.capabilities.input = ["text"]
|
||||||
model.capabilities.output = ["text"]
|
model.capabilities.output = ["text"]
|
||||||
model.cost = cost(10, 10)
|
model.cost = [...cost(10, 10)]
|
||||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
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: {} } },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(openrouter.id, (item) => {
|
||||||
},
|
item.endpoint = openrouter.endpoint
|
||||||
)
|
item.options = openrouter.options
|
||||||
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("nvidia"), cancel: false })
|
})
|
||||||
expect(result.provider.options.headers).toEqual({
|
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
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: {} } },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(item.id, (draft) => {
|
||||||
},
|
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: {} } },
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(item.id, (draft) => {
|
||||||
},
|
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: {} },
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(item.id, (draft) => {
|
||||||
},
|
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", {
|
||||||
{
|
|
||||||
provider: provider("openrouter", {
|
|
||||||
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: {} },
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
cancel: false,
|
catalog.provider.update(item.id, (draft) => {
|
||||||
},
|
draft.options = item.options
|
||||||
)
|
})
|
||||||
|
})
|
||||||
|
|
||||||
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",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.withSpan("Cli.debug.v2"),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
LocationServiceMap.get({
|
LocationServiceMap.get({
|
||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.provide(Runtime),
|
Effect.provide(LocationServiceMap.layer),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 +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: "*" }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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: "*" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -3529,7 +3529,7 @@ export type ProviderV2Info = {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
via: "auth"
|
via: "account"
|
||||||
service: string
|
service: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user