Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ecf27f389 | ||
|
|
548446a990 | ||
|
|
ddd6eb4496 | ||
|
|
12bbe84360 | ||
|
|
661df8fcf8 | ||
|
|
16fb6dac8d | ||
|
|
93131b6e4c | ||
|
|
8643c0721e |
@@ -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"
|
||||
+141
-53
@@ -1,6 +1,6 @@
|
||||
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 { ModelV2 } from "./model"
|
||||
import { PluginV2 } from "./plugin"
|
||||
@@ -8,9 +8,9 @@ import { ProviderV2 } from "./provider"
|
||||
import { Location } from "./location"
|
||||
import { EventV2 } from "./event"
|
||||
|
||||
type ProviderRecord = {
|
||||
export type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
models: HashMap.HashMap<ModelV2.ID, ModelV2.Info>
|
||||
models: Map<ModelV2.ID, ModelV2.Info>
|
||||
}
|
||||
|
||||
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 {
|
||||
readonly loader: () => Effect.Effect<Loader, never, Scope.Scope>
|
||||
readonly provider: {
|
||||
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 available: () => Effect.Effect<ProviderV2.Info[]>
|
||||
}
|
||||
@@ -46,11 +62,6 @@ export interface Interface {
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
) => 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 available: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
|
||||
@@ -69,9 +80,11 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
yield* Location.Service
|
||||
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
|
||||
let loaders: { update: (ctx: Context) => void }[] = []
|
||||
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
|
||||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const resolve = (model: ModelV2.Info) => {
|
||||
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
|
||||
@@ -112,29 +125,127 @@ export const layer = Layer.effect(
|
||||
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 = {
|
||||
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: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
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* () {
|
||||
return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider)
|
||||
}),
|
||||
@@ -149,39 +260,16 @@ export const layer = Layer.effect(
|
||||
model: {
|
||||
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
|
||||
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 })
|
||||
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* () {
|
||||
return pipe(
|
||||
records,
|
||||
HashMap.toValues,
|
||||
Array.flatMap((record) => HashMap.toValues(record.models)),
|
||||
Array.flatMap((record) => globalThis.Array.from(record.models.values())),
|
||||
Array.map(resolve),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
)
|
||||
@@ -217,12 +305,12 @@ export const layer = Layer.effect(
|
||||
if (!record) return Option.none<ModelV2.Info>()
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
const candidates = pipe(
|
||||
HashMap.toValues(record.models),
|
||||
globalThis.Array.from(record.models.values()),
|
||||
Array.filter(
|
||||
(model) =>
|
||||
model.providerID === providerID &&
|
||||
@@ -266,4 +354,4 @@ export const layer = Layer.effect(
|
||||
|
||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.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 { Location } from "./location"
|
||||
import { withStatics } from "./schema"
|
||||
@@ -153,5 +155,3 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export * as EventV2 from "./event"
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Catalog } from "./catalog"
|
||||
import { PluginBoot } from "./plugin/boot"
|
||||
|
||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||
lookup: (ref: Location.Ref) => {
|
||||
const location = Layer.succeed(Location.Service, Location.Service.of(ref))
|
||||
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location))
|
||||
},
|
||||
lookup: (ref: Location.Ref) =>
|
||||
Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
|
||||
Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]),
|
||||
),
|
||||
idleTimeToLive: "5 minutes",
|
||||
dependencies: [],
|
||||
}) {}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Flock } from "./util/flock"
|
||||
import { Hash } from "./util/hash"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
||||
import { EventV2 } from "./event"
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
@@ -105,6 +106,13 @@ export const Provider = Schema.Struct({
|
||||
|
||||
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
|
||||
|
||||
export interface Interface {
|
||||
@@ -118,6 +126,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const events = yield* EventV2.Service
|
||||
const http = HttpClient.filterStatusOk(
|
||||
(yield* HttpClient.HttpClient).pipe(
|
||||
HttpClient.retryTransient({
|
||||
@@ -197,6 +206,7 @@ export const layer = Layer.effect(
|
||||
if (!force && (yield* fresh())) return
|
||||
yield* fetchAndWrite()
|
||||
yield* invalidate
|
||||
yield* events.publish(Event.Refreshed, {})
|
||||
}),
|
||||
).pipe(
|
||||
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(AppFileSystem.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
|
||||
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 type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { type ProviderV2 } from "./provider"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect"
|
||||
import type { ModelV2 } from "./model"
|
||||
import type { AgentV2 } from "./agent"
|
||||
import type { Catalog } from "./catalog"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
type HookSpec = {
|
||||
"provider.update": {
|
||||
input: {}
|
||||
output: {
|
||||
provider: ProviderV2.Info
|
||||
cancel: boolean
|
||||
}
|
||||
"catalog.transform": {
|
||||
input: Catalog.Context
|
||||
output: {}
|
||||
}
|
||||
"model.update": {
|
||||
input: {}
|
||||
output: {
|
||||
model: ModelV2.Info
|
||||
cancel: boolean
|
||||
"account.switched": {
|
||||
input: {
|
||||
serviceID: import("./account").AccountV2.ServiceID
|
||||
from?: import("./account").AccountV2.ID
|
||||
to?: import("./account").AccountV2.ID
|
||||
}
|
||||
output: {}
|
||||
}
|
||||
"aisdk.language": {
|
||||
input: {
|
||||
@@ -44,6 +43,27 @@ type HookSpec = {
|
||||
sdk?: any
|
||||
}
|
||||
}
|
||||
"agent.update": {
|
||||
input: {}
|
||||
output: {
|
||||
agent: AgentV2.Info
|
||||
cancel: boolean
|
||||
}
|
||||
}
|
||||
"agent.remove": {
|
||||
input: {
|
||||
agent: AgentV2.Info
|
||||
}
|
||||
output: {
|
||||
cancel: boolean
|
||||
}
|
||||
}
|
||||
"agent.default": {
|
||||
input: {}
|
||||
output: {
|
||||
agent?: AgentV2.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Hooks = {
|
||||
@@ -61,15 +81,25 @@ export type HookFunctions = {
|
||||
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
|
||||
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> }) {
|
||||
return input
|
||||
}
|
||||
|
||||
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 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>(
|
||||
name: Name,
|
||||
input: HookInput<Name>,
|
||||
@@ -85,21 +115,33 @@ export const layer = Layer.effect(
|
||||
let hooks: {
|
||||
id: ID
|
||||
hooks: HookFunctions
|
||||
scope: Scope.Closeable
|
||||
}[] = []
|
||||
const added = yield* PubSub.unbounded<ID>()
|
||||
|
||||
yield* Effect.addFinalizer(() => PubSub.shutdown(added))
|
||||
|
||||
const svc = Service.of({
|
||||
add: Effect.fn("Plugin.add")(function* (input) {
|
||||
const result = yield* input.effect
|
||||
if (!result) return
|
||||
const existing = hooks.find((item) => item.id === input.id)
|
||||
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.filter((item) => item.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) {
|
||||
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 event = {
|
||||
...input,
|
||||
@@ -114,6 +156,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
for (const item of hooks) {
|
||||
if (id !== ID.make("*") && item.id !== id) continue
|
||||
const match = item.hooks[name]
|
||||
if (!match) continue
|
||||
yield* match(event as any).pipe(
|
||||
@@ -133,7 +176,9 @@ export const layer = Layer.effect(
|
||||
return event as any
|
||||
}),
|
||||
remove: Effect.fn("Plugin.remove")(function* (id) {
|
||||
const existing = hooks.find((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
|
||||
|
||||
@@ -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"
|
||||
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { AuthV2 } from "../auth"
|
||||
import { Context, Deferred, Effect, Layer, Scope } from "effect"
|
||||
import { AccountV2 } from "../account"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { EventV2 } from "../event"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { AuthPlugin } from "./auth"
|
||||
import { AccountPlugin } from "./account"
|
||||
import { EnvPlugin } from "./env"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
|
||||
type Plugin = {
|
||||
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 {
|
||||
@@ -21,51 +25,57 @@ export interface Interface {
|
||||
|
||||
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> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const auth = yield* AuthV2.Service
|
||||
const npm = yield* Npm.Service
|
||||
const done = yield* Deferred.make<void>()
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const accounts = yield* AccountV2.Service
|
||||
const npm = yield* Npm.Service
|
||||
const events = yield* EventV2.Service
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||
yield* plugin.add({
|
||||
id: input.id,
|
||||
effect: input.effect.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(AuthV2.Service, auth),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
),
|
||||
})
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||
yield* plugin.add({
|
||||
id: input.id,
|
||||
effect: input.effect.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(AgentV2.Service, agent),
|
||||
Effect.provideService(AccountV2.Service, accounts),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const boot = Effect.gen(function* () {
|
||||
yield* add(EnvPlugin)
|
||||
yield* add(AuthPlugin)
|
||||
for (const item of ProviderPlugins) {
|
||||
yield* add(item)
|
||||
}
|
||||
yield* add(ModelsDevPlugin)
|
||||
}).pipe(Effect.withSpan("PluginBoot.boot"))
|
||||
const boot = Effect.gen(function* () {
|
||||
yield* add(EnvPlugin)
|
||||
yield* add(AccountPlugin)
|
||||
for (const item of ProviderPlugins) {
|
||||
yield* add(item)
|
||||
}
|
||||
yield* add(ModelsDevPlugin)
|
||||
}).pipe(Effect.withSpan("PluginBoot.boot"))
|
||||
|
||||
yield* boot.pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Deferred.done(done, exit)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* boot.pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Deferred.done(done, exit)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
wait: () => Deferred.await(done),
|
||||
})
|
||||
}),
|
||||
)
|
||||
return Service.of({
|
||||
wait: () => Deferred.await(done),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AgentV2.defaultLayer),
|
||||
Layer.provide(Catalog.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(PluginV2.defaultLayer),
|
||||
Layer.provide(Layer.orDie(AuthV2.defaultLayer)),
|
||||
Layer.provide(AccountV2.defaultLayer),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -5,12 +5,16 @@ export const EnvPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("env"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
const key = evt.provider.env.find((item) => process.env[item])
|
||||
if (!key) return
|
||||
evt.provider.enabled = {
|
||||
via: "env",
|
||||
name: key,
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
const key = item.provider.env.find((env) => process.env[env])
|
||||
if (!key) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.enabled = {
|
||||
via: "env",
|
||||
name: key,
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { DateTime, Effect, Scope, Stream } from "effect"
|
||||
import { Catalog } from "../catalog"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { PluginV2 } from "../plugin"
|
||||
@@ -54,55 +55,66 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
for (const item of Object.values(yield* modelsDev.get())) {
|
||||
const providerID = ProviderV2.ID.make(item.id)
|
||||
yield* catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = item.name
|
||||
provider.env = [...item.env]
|
||||
provider.endpoint = item.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
package: item.npm,
|
||||
url: item.api,
|
||||
}
|
||||
: {
|
||||
type: "unknown",
|
||||
}
|
||||
})
|
||||
|
||||
for (const model of Object.values(item.models)) {
|
||||
const modelID = ModelV2.ID.make(model.id)
|
||||
yield* catalog.model
|
||||
.update(providerID, modelID, (draft) => {
|
||||
draft.name = model.name
|
||||
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
|
||||
draft.endpoint = model.provider?.npm
|
||||
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)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = item.name
|
||||
provider.env = [...item.env]
|
||||
provider.endpoint = item.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
package: model.provider?.npm,
|
||||
url: model.provider.api,
|
||||
package: item.npm,
|
||||
url: item.api,
|
||||
}
|
||||
: {
|
||||
type: "unknown",
|
||||
}
|
||||
draft.capabilities = {
|
||||
tools: model.tool_call,
|
||||
input: [...(model.modalities?.input ?? [])],
|
||||
output: [...(model.modalities?.output ?? [])],
|
||||
}
|
||||
draft.variants = variants(model)
|
||||
draft.time.released = released(model.release_date)
|
||||
draft.cost = cost(model.cost)
|
||||
draft.status = model.status ?? "active"
|
||||
draft.enabled = true
|
||||
draft.limit = {
|
||||
context: model.limit.context,
|
||||
input: model.limit.input,
|
||||
output: model.limit.output,
|
||||
}
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
}
|
||||
|
||||
for (const model of Object.values(item.models)) {
|
||||
const modelID = ModelV2.ID.make(model.id)
|
||||
catalog.model.update(providerID, modelID, (draft) => {
|
||||
draft.name = model.name
|
||||
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
|
||||
draft.endpoint = model.provider?.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
package: model.provider?.npm,
|
||||
url: model.provider.api,
|
||||
}
|
||||
: {
|
||||
type: "unknown",
|
||||
}
|
||||
draft.capabilities = {
|
||||
tools: model.tool_call,
|
||||
input: [...(model.modalities?.input ?? [])],
|
||||
output: [...(model.modalities?.output ?? [])],
|
||||
}
|
||||
draft.variants = variants(model)
|
||||
draft.time.released = released(model.release_date)
|
||||
draft.cost = cost(model.cost)
|
||||
draft.status = model.status ?? "active"
|
||||
draft.enabled = true
|
||||
draft.limit = {
|
||||
context: model.limit.context,
|
||||
input: model.limit.input,
|
||||
output: model.limit.output,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
yield* refresh()
|
||||
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.runForEach(() => refresh()),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
|
||||
})
|
||||
|
||||
@@ -50,14 +50,19 @@ export const AmazonBedrockPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("amazon-bedrock"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.amazonBedrock) return
|
||||
if (evt.provider.endpoint.type !== "aisdk") return
|
||||
if (typeof evt.provider.options.aisdk.provider.endpoint !== "string") return
|
||||
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
|
||||
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
||||
evt.provider.endpoint.url = evt.provider.options.aisdk.provider.endpoint
|
||||
delete evt.provider.options.aisdk.provider.endpoint
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
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
|
||||
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
||||
provider.endpoint.url = provider.options.aisdk.provider.endpoint
|
||||
delete provider.options.aisdk.provider.endpoint
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/amazon-bedrock") return
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const AnthropicPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("anthropic"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.anthropic) return
|
||||
evt.provider.options.headers["anthropic-beta"] =
|
||||
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
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"
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/anthropic") return
|
||||
|
||||
@@ -14,12 +14,18 @@ export const AzurePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("azure"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.azure) return
|
||||
const configured = evt.provider.options.aisdk.provider.resourceName
|
||||
const resourceName =
|
||||
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
|
||||
if (resourceName) evt.provider.options.aisdk.provider.resourceName = resourceName
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
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 =
|
||||
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
|
||||
if (!resourceName) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.options.aisdk.provider.resourceName = resourceName
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/azure") return
|
||||
@@ -49,11 +55,17 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("azure-cognitive-services"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("azure-cognitive-services")) return
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
if (resourceName)
|
||||
evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
|
||||
if (!resourceName) return
|
||||
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) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const CerebrasPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("cerebras"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("cerebras")) return
|
||||
evt.provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
|
||||
"catalog.transform": Effect.fn(function* (ctx) {
|
||||
for (const item of ctx.data) {
|
||||
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) {
|
||||
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 {
|
||||
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.
|
||||
const gatewayId =
|
||||
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"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== providerID) return
|
||||
if (evt.provider.endpoint.type !== "aisdk") return
|
||||
if (evt.provider.endpoint.url) return
|
||||
|
||||
const accountId = resolveAccountId(evt.provider.options.aisdk.provider)
|
||||
if (accountId) evt.provider.endpoint.url = workersEndpoint(accountId)
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.data.find((record) => record.provider.id === providerID)
|
||||
if (!item) return
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (provider.endpoint.type !== "aisdk") return
|
||||
if (provider.endpoint.url) return
|
||||
const accountId = resolveAccountId(provider.options.aisdk.provider)
|
||||
if (accountId) provider.endpoint.url = workersEndpoint(accountId)
|
||||
})
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
|
||||
@@ -15,9 +15,6 @@ export const GithubCopilotPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("github-copilot"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.githubCopilot) return
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/github-copilot") return
|
||||
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.chat(evt.model.apiID)
|
||||
}),
|
||||
"model.update": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// 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
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
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,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -57,20 +57,26 @@ export const GoogleVertexPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("google-vertex"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.googleVertex) return
|
||||
const project = resolveProject(evt.provider.options.aisdk.provider)
|
||||
const location = String(resolveLocation(evt.provider.options.aisdk.provider))
|
||||
if (project) evt.provider.options.aisdk.provider.project = project
|
||||
evt.provider.options.aisdk.provider.location = location
|
||||
if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.url) {
|
||||
evt.provider.endpoint.url = replaceVertexVars(evt.provider.endpoint.url, project, location)
|
||||
}
|
||||
if (
|
||||
evt.provider.endpoint.type === "aisdk" &&
|
||||
evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible")
|
||||
) {
|
||||
evt.provider.options.aisdk.provider.fetch = authFetch(evt.provider.options.aisdk.provider.fetch)
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (
|
||||
item.provider.endpoint.package !== "@ai-sdk/google-vertex" &&
|
||||
!item.provider.endpoint.package.includes("@ai-sdk/openai-compatible")
|
||||
)
|
||||
continue
|
||||
const project = resolveProject(item.provider.options.aisdk.provider)
|
||||
const location = String(resolveLocation(item.provider.options.aisdk.provider))
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
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 (provider.endpoint.type === "aisdk" && provider.endpoint.package.includes("@ai-sdk/openai-compatible")) {
|
||||
provider.options.aisdk.provider.fetch = authFetch(provider.options.aisdk.provider.fetch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
@@ -102,20 +108,25 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("google-vertex-anthropic"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("google-vertex-anthropic")) return
|
||||
const project =
|
||||
evt.provider.options.aisdk.provider.project ??
|
||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||
process.env.GCP_PROJECT ??
|
||||
process.env.GCLOUD_PROJECT
|
||||
const location =
|
||||
evt.provider.options.aisdk.provider.location ??
|
||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||
process.env.VERTEX_LOCATION ??
|
||||
"global"
|
||||
if (project) evt.provider.options.aisdk.provider.project = project
|
||||
evt.provider.options.aisdk.provider.location = location
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
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 =
|
||||
item.provider.options.aisdk.provider.project ??
|
||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||
process.env.GCP_PROJECT ??
|
||||
process.env.GCLOUD_PROJECT
|
||||
const location =
|
||||
item.provider.options.aisdk.provider.location ??
|
||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||
process.env.VERTEX_LOCATION ??
|
||||
"global"
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (project) provider.options.aisdk.provider.project = project
|
||||
provider.options.aisdk.provider.location = location
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const KiloPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("kilo"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("kilo")) return
|
||||
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] = "opencode"
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
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.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 { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const LLMGatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("llmgateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("llmgateway")) return
|
||||
if (evt.provider.enabled === false) return
|
||||
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] = "opencode"
|
||||
evt.provider.options.headers["X-Source"] = "opencode"
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
if (item.provider.enabled === false) continue
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
|
||||
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 { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const NvidiaPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("nvidia"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return
|
||||
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] = "opencode"
|
||||
evt.provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
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.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
|
||||
evt.language = evt.sdk.responses(evt.model.apiID)
|
||||
}),
|
||||
"model.update": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openai) return
|
||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||
// chat-completions-only model, so remove it only from OpenAI's catalog.
|
||||
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
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
|
||||
// chat-completions-only model, so hide it only from OpenAI's catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -7,20 +7,25 @@ export const OpencodePlugin = PluginV2.define({
|
||||
effect: Effect.gen(function* () {
|
||||
let hasKey = false
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.opencode) return
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.opencode)
|
||||
if (!item) return
|
||||
hasKey = Boolean(
|
||||
process.env.OPENCODE_API_KEY ||
|
||||
evt.provider.env.some((item) => process.env[item]) ||
|
||||
evt.provider.options.aisdk.provider.apiKey ||
|
||||
(evt.provider.enabled && evt.provider.enabled.via === "auth"),
|
||||
item.provider.env.some((env) => process.env[env]) ||
|
||||
item.provider.options.aisdk.provider.apiKey ||
|
||||
(item.provider.enabled && item.provider.enabled.via === "account"),
|
||||
)
|
||||
if (!hasKey) evt.provider.options.aisdk.provider.apiKey = "public"
|
||||
}),
|
||||
"model.update": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.opencode) return
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) provider.options.aisdk.provider.apiKey = "public"
|
||||
})
|
||||
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 { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const OpenRouterPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("openrouter"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.openrouter) return
|
||||
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] = "opencode"
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
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) {
|
||||
if (evt.package !== "@openrouter/ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
|
||||
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"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("vercel")) return
|
||||
evt.provider.options.headers["http-referer"] = "https://opencode.ai/"
|
||||
evt.provider.options.headers["x-title"] = "opencode"
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.data) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
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) {
|
||||
if (evt.package !== "@ai-sdk/vercel") return
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const ZenmuxPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("zenmux"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.make("zenmux")) return
|
||||
evt.provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/"
|
||||
evt.provider.options.headers["X-Title"] ??= "opencode"
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
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.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,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("auth"),
|
||||
via: Schema.Literal("account"),
|
||||
service: Schema.String,
|
||||
}),
|
||||
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))),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
+124
-117
@@ -1,5 +1,5 @@
|
||||
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 { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -22,24 +22,24 @@ describe("CatalogV2", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const load = yield* catalog.loader()
|
||||
|
||||
yield* catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://default.example.com",
|
||||
}
|
||||
provider.options.aisdk.provider.baseURL = "https://override.example.com"
|
||||
})
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://default.example.com",
|
||||
}
|
||||
provider.options.aisdk.provider.baseURL = "https://override.example.com"
|
||||
}),
|
||||
)
|
||||
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
|
||||
expect(provider.endpoint).toEqual({
|
||||
expect((yield* catalog.provider.get(providerID)).endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://override.example.com",
|
||||
})
|
||||
expect(provider.options.aisdk.provider.baseURL).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -48,56 +48,27 @@ describe("CatalogV2", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
const load = yield* catalog.loader()
|
||||
|
||||
yield* catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://provider.example.com",
|
||||
}
|
||||
})
|
||||
yield* catalog.model.update(providerID, modelID, (model) => {
|
||||
model.endpoint = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://model.example.com",
|
||||
}
|
||||
model.options.aisdk.provider.baseURL = "https://override.example.com"
|
||||
yield* load((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://provider.example.com",
|
||||
}
|
||||
})
|
||||
catalog.model.update(providerID, modelID, (model) => {
|
||||
model.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://model.example.com" }
|
||||
model.options.aisdk.provider.baseURL = "https://override.example.com"
|
||||
})
|
||||
})
|
||||
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
|
||||
expect(model.endpoint).toEqual({
|
||||
expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
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 +77,20 @@ describe("CatalogV2", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
const load = yield* catalog.loader()
|
||||
|
||||
yield* catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://provider.example.com",
|
||||
}
|
||||
yield* load((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
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(model.endpoint).toEqual({
|
||||
expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://provider.example.com",
|
||||
@@ -126,58 +98,91 @@ describe("CatalogV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs provider hooks after baseURL is normalized", () =>
|
||||
it.effect("runs catalog transform hooks after baseURL is normalized", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const seen: unknown[] = []
|
||||
const load = yield* catalog.loader()
|
||||
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("test"),
|
||||
effect: Effect.succeed({
|
||||
"provider.update": (evt) =>
|
||||
"catalog.transform": (evt) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(evt.provider.endpoint.type)
|
||||
if (evt.provider.endpoint.type === "aisdk") seen.push(evt.provider.endpoint.url)
|
||||
seen.push(evt.provider.options.aisdk.provider.baseURL)
|
||||
const item = evt.data.find((record) => record.provider.id === providerID)
|
||||
if (!item) return
|
||||
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) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
}
|
||||
provider.options.aisdk.provider.baseURL = "https://provider.example.com"
|
||||
})
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
provider.options.aisdk.provider.baseURL = "https://provider.example.com"
|
||||
}),
|
||||
)
|
||||
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const modelID = ModelV2.ID.make("model")
|
||||
const load = yield* catalog.loader()
|
||||
|
||||
yield* catalog.provider.update(providerID, (provider) => {
|
||||
provider.options.headers.provider = "provider"
|
||||
provider.options.headers.shared = "provider"
|
||||
provider.options.body.provider = true
|
||||
provider.options.aisdk.provider.provider = true
|
||||
})
|
||||
yield* catalog.model.update(providerID, modelID, (model) => {
|
||||
model.options.headers.model = "model"
|
||||
model.options.headers.shared = "model"
|
||||
model.options.body.model = true
|
||||
model.options.aisdk.provider.model = true
|
||||
model.options.aisdk.request.request = true
|
||||
yield* load((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.options.headers.provider = "provider"
|
||||
provider.options.headers.shared = "provider"
|
||||
provider.options.body.provider = true
|
||||
provider.options.aisdk.provider.provider = true
|
||||
})
|
||||
catalog.model.update(providerID, modelID, (model) => {
|
||||
model.options.headers.model = "model"
|
||||
model.options.headers.shared = "model"
|
||||
model.options.body.model = true
|
||||
model.options.aisdk.provider.model = true
|
||||
model.options.aisdk.request.request = true
|
||||
})
|
||||
})
|
||||
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
|
||||
expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
|
||||
expect(model.options.body).toEqual({ provider: true, model: true })
|
||||
expect(model.options.aisdk.provider).toEqual({ provider: true, model: true })
|
||||
@@ -189,20 +194,21 @@ describe("CatalogV2", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const load = yield* catalog.loader()
|
||||
|
||||
yield* catalog.provider.update(providerID, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
yield* catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(1000)
|
||||
})
|
||||
yield* catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(2000)
|
||||
yield* load((catalog) => {
|
||||
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)
|
||||
})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(2000)
|
||||
})
|
||||
})
|
||||
|
||||
const model = yield* catalog.model.default()
|
||||
|
||||
expect(Option.getOrUndefined(model)?.id).toMatch("new")
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -210,24 +216,25 @@ describe("CatalogV2", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
const load = yield* catalog.loader()
|
||||
|
||||
yield* catalog.provider.update(providerID, () => {})
|
||||
yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
|
||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||
})
|
||||
yield* catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
|
||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||
yield* load((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
|
||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||
})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
|
||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||
})
|
||||
})
|
||||
|
||||
const model = yield* catalog.model.small(providerID)
|
||||
|
||||
expect(Option.getOrUndefined(model)?.id).toMatch("expensive-mini")
|
||||
expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { it } from "./lib/effect"
|
||||
import { rm, writeFile, utimes, mkdir } from "fs/promises"
|
||||
import path from "path"
|
||||
@@ -92,6 +93,7 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
|
||||
Layer.fresh(ModelsDev.layer).pipe(
|
||||
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
|
||||
const writeCache = (data: object, mtimeMs?: number) =>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
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"
|
||||
|
||||
function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
|
||||
@@ -20,27 +22,30 @@ describe("AmazonBedrockPlugin", () => {
|
||||
it.effect("moves endpoint option to endpoint URL", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AmazonBedrockPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("amazon-bedrock", {
|
||||
options: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} },
|
||||
},
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
expect(result.provider.endpoint).toEqual({
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const bedrock = provider("amazon-bedrock", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
|
||||
options: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} },
|
||||
},
|
||||
})
|
||||
catalog.provider.update(bedrock.id, (item) => {
|
||||
item.endpoint = bedrock.endpoint
|
||||
item.options = bedrock.options
|
||||
})
|
||||
})
|
||||
const result = yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)
|
||||
expect(result.endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
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 { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it, model, provider } from "./provider-helper"
|
||||
|
||||
describe("AnthropicPlugin", () => {
|
||||
it.effect("applies legacy beta headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AnthropicPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("anthropic", {
|
||||
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
expect(result.provider.options.headers["anthropic-beta"]).toBe(
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("anthropic", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" },
|
||||
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.options = item.options
|
||||
})
|
||||
})
|
||||
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",
|
||||
)
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AnthropicPlugin)
|
||||
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
|
||||
expect(result.provider.options.headers["anthropic-beta"]).toBeUndefined()
|
||||
const load = yield* catalog.loader()
|
||||
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 { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
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"
|
||||
|
||||
describe("AzureCognitiveServicesPlugin", () => {
|
||||
@@ -9,20 +11,22 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzureCognitiveServicesPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{ provider: provider("azure-cognitive-services"), cancel: false },
|
||||
)
|
||||
expect(result.provider.endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
|
||||
item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
})
|
||||
})
|
||||
expect(result.provider.options.aisdk.provider.baseURL).toBe(
|
||||
"https://cognitive.cognitiveservices.azure.com/openai",
|
||||
)
|
||||
expect(result.provider.options.aisdk.provider.resourceName).toBeUndefined()
|
||||
const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
|
||||
expect(result.endpoint).toEqual({
|
||||
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 }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzureCognitiveServicesPlugin)
|
||||
const azure = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{ provider: provider("azure-cognitive-services"), cancel: false },
|
||||
)
|
||||
const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
|
||||
expect(azure.provider.options.aisdk.provider.baseURL).toBeUndefined()
|
||||
expect(azure.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
|
||||
expect(other.provider.options.aisdk.provider.baseURL).toBeUndefined()
|
||||
expect(other.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const azure = provider("azure-cognitive-services", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
||||
})
|
||||
const openai = provider("openai")
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.endpoint = azure.endpoint
|
||||
})
|
||||
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 { 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 { 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 { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
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", () => {
|
||||
it.effect("resolves resourceName from env", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzurePlugin)
|
||||
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false })
|
||||
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
|
||||
const load = yield* catalog.loader()
|
||||
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" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzurePlugin)
|
||||
const azure = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("azure", {
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
|
||||
})
|
||||
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(azure.provider.options.aisdk.provider.resourceName).toBe("from-config")
|
||||
expect(other.provider.options.aisdk.provider.resourceName).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.resourceName).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
itWithAuth.effect("prefers auth resourceName over env", () =>
|
||||
itWithAccount.effect("prefers account resourceName over env", () =>
|
||||
withEnv(
|
||||
{
|
||||
AZURE_RESOURCE_NAME: "from-env",
|
||||
@@ -51,23 +73,36 @@ describe("AzurePlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const auth = yield* AuthV2.Service
|
||||
yield* auth.create({
|
||||
serviceID: AuthV2.ServiceID.make("azure"),
|
||||
credential: new AuthV2.ApiKeyCredential({
|
||||
const accounts = yield* AccountV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* accounts.create({
|
||||
serviceID: AccountV2.ServiceID.make("azure"),
|
||||
credential: new AccountV2.ApiKeyCredential({
|
||||
type: "api",
|
||||
key: "key",
|
||||
metadata: { resourceName: "from-auth" },
|
||||
metadata: { resourceName: "from-account" },
|
||||
}),
|
||||
active: true,
|
||||
})
|
||||
yield* plugin.add({
|
||||
...AuthPlugin,
|
||||
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
|
||||
...AccountPlugin,
|
||||
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)
|
||||
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false })
|
||||
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-auth")
|
||||
const load = yield* catalog.loader()
|
||||
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" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzurePlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("azure", {
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.endpoint = azure.endpoint
|
||||
item.options = azure.options
|
||||
})
|
||||
})
|
||||
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" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(AzurePlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("azure", {
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } },
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.endpoint = azure.endpoint
|
||||
item.options = azure.options
|
||||
})
|
||||
})
|
||||
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 { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it, model, provider } from "./provider-helper"
|
||||
|
||||
const cerebrasOptions: Record<string, unknown>[] = []
|
||||
@@ -20,27 +22,30 @@ describe("CerebrasPlugin", () => {
|
||||
it.effect("applies the legacy integration header", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(CerebrasPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("cerebras", {
|
||||
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
expect(result.provider.options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" })
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
|
||||
item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" }
|
||||
item.options.headers.Existing = "1"
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).options.headers).toEqual({
|
||||
Existing: "1",
|
||||
"X-Cerebras-3rd-Party-Integration": "opencode",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non-Cerebras providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(CerebrasPlugin)
|
||||
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("groq"), cancel: false })
|
||||
expect(result.provider.options.headers).toEqual({})
|
||||
const load = yield* catalog.loader()
|
||||
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 { 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 { 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 { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
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") {
|
||||
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" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(CloudflareWorkersAIPlugin)
|
||||
const updated = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{ provider: provider("cloudflare-workers-ai"), cancel: false },
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "test-provider" }
|
||||
}),
|
||||
)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))
|
||||
const sdk = yield* plugin.trigger(
|
||||
"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",
|
||||
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(updated.provider.endpoint).toEqual({
|
||||
expect(provider.endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1",
|
||||
@@ -63,18 +78,15 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(CloudflareWorkersAIPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("cloudflare-workers-ai", {
|
||||
endpoint: { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
const load = yield* catalog.loader()
|
||||
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" }
|
||||
}),
|
||||
)
|
||||
expect(result.provider.endpoint).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
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(
|
||||
{
|
||||
CLOUDFLARE_ACCOUNT_ID: undefined,
|
||||
@@ -113,30 +125,37 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const auth = yield* AuthV2.Service
|
||||
yield* auth.create({
|
||||
serviceID: AuthV2.ServiceID.make("cloudflare-workers-ai"),
|
||||
credential: new AuthV2.ApiKeyCredential({
|
||||
const accounts = yield* AccountV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* accounts.create({
|
||||
serviceID: AccountV2.ServiceID.make("cloudflare-workers-ai"),
|
||||
credential: new AccountV2.ApiKeyCredential({
|
||||
type: "api",
|
||||
key: "auth-key",
|
||||
metadata: { accountId: "auth-acct" },
|
||||
key: "account-key",
|
||||
metadata: { accountId: "account-acct" },
|
||||
}),
|
||||
active: true,
|
||||
})
|
||||
yield* plugin.add({
|
||||
...AuthPlugin,
|
||||
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
|
||||
...AccountPlugin,
|
||||
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)
|
||||
const updated = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{ provider: provider("cloudflare-workers-ai"), cancel: false },
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
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",
|
||||
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" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(CloudflareWorkersAIPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("cloudflare-workers-ai", {
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { accountId: "configured-acct" }, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "test-provider" }
|
||||
provider.options.aisdk.provider.accountId = "configured-acct"
|
||||
}),
|
||||
)
|
||||
expect(result.provider.endpoint).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
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 { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { fakeSelectorSdk, it, model } from "./provider-helper"
|
||||
|
||||
describe("GithubCopilotPlugin", () => {
|
||||
@@ -145,29 +147,35 @@ 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* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GithubCopilotPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("github-copilot", "gpt-5-chat-latest"), cancel: false },
|
||||
)
|
||||
expect(result.cancel).toBe(true)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
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* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GithubCopilotPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("custom-copilot", "gpt-5-chat-latest"), cancel: false },
|
||||
)
|
||||
expect(result.cancel).toBe(false)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
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 { 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 { 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 { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
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>[] = []
|
||||
|
||||
@@ -22,7 +26,15 @@ void mock.module("gitlab-ai-provider", () => ({
|
||||
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", () => {
|
||||
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(
|
||||
{
|
||||
GITLAB_TOKEN: "env-token",
|
||||
@@ -150,33 +162,41 @@ describe("GitLabPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
const auth = yield* AuthV2.Service
|
||||
yield* auth.create({
|
||||
serviceID: AuthV2.ServiceID.make("gitlab"),
|
||||
credential: new AuthV2.ApiKeyCredential({ type: "api", key: "auth-token" }),
|
||||
active: true,
|
||||
const accounts = yield* AccountV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* accounts.create({
|
||||
serviceID: AccountV2.ServiceID.make("gitlab"),
|
||||
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "account-token" }),
|
||||
})
|
||||
yield* plugin.add({
|
||||
...AuthPlugin,
|
||||
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
|
||||
...AccountPlugin,
|
||||
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)
|
||||
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(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("gitlab", "claude"),
|
||||
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(
|
||||
{
|
||||
GITLAB_TOKEN: undefined,
|
||||
@@ -185,33 +205,41 @@ describe("GitLabPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
const auth = yield* AuthV2.Service
|
||||
yield* auth.create({
|
||||
serviceID: AuthV2.ServiceID.make("gitlab"),
|
||||
credential: new AuthV2.OAuthCredential({
|
||||
const accounts = yield* AccountV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* accounts.create({
|
||||
serviceID: AccountV2.ServiceID.make("gitlab"),
|
||||
credential: new AccountV2.OAuthCredential({
|
||||
type: "oauth",
|
||||
refresh: "refresh-token",
|
||||
access: "oauth-token",
|
||||
access: "account-oauth-token",
|
||||
expires: 9999999999999,
|
||||
}),
|
||||
active: true,
|
||||
})
|
||||
yield* plugin.add({
|
||||
...AuthPlugin,
|
||||
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
|
||||
...AccountPlugin,
|
||||
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)
|
||||
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(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("gitlab", "claude"),
|
||||
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 { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
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", () => {
|
||||
it.effect("resolves legacy project and location env on provider update", () =>
|
||||
@@ -18,14 +20,17 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{ provider: provider("google-vertex-anthropic"), cancel: false },
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
}),
|
||||
)
|
||||
expect(result.provider.options.aisdk.provider.project).toBe("cloud-project")
|
||||
expect(result.provider.options.aisdk.provider.location).toBe("cloud-location")
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
|
||||
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" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("google-vertex-anthropic", {
|
||||
options: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: { provider: { project: "configured-project", location: "configured-location" }, request: {} },
|
||||
},
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
provider.options.aisdk.provider.project = "configured-project"
|
||||
provider.options.aisdk.provider.location = "configured-location"
|
||||
}),
|
||||
)
|
||||
expect(result.provider.options.aisdk.provider.project).toBe("configured-project")
|
||||
expect(result.provider.options.aisdk.provider.location).toBe("configured-location")
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
|
||||
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 { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
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>[] = []
|
||||
|
||||
@@ -43,24 +45,22 @@ describe("GoogleVertexPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("google-vertex", {
|
||||
endpoint: {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
},
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
}
|
||||
}),
|
||||
)
|
||||
expect(result.provider.options.aisdk.provider.project).toBe("google-cloud-project")
|
||||
expect(result.provider.options.aisdk.provider.location).toBe("google-vertex-location")
|
||||
expect(result.provider.endpoint).toEqual({
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
|
||||
expect(provider.options.aisdk.provider.project).toBe("google-cloud-project")
|
||||
expect(provider.options.aisdk.provider.location).toBe("google-vertex-location")
|
||||
expect(provider.endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
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* () {
|
||||
vertexOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexPlugin)
|
||||
const updated = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("google-vertex", {
|
||||
endpoint: {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
},
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
}
|
||||
}),
|
||||
)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
@@ -111,8 +109,8 @@ describe("GoogleVertexPlugin", () => {
|
||||
{},
|
||||
)
|
||||
|
||||
expect(updated.provider.options.aisdk.provider.project).toBe("vertex-project")
|
||||
expect(updated.provider.endpoint).toEqual({
|
||||
expect(provider.options.aisdk.provider.project).toBe("vertex-project")
|
||||
expect(provider.endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4",
|
||||
@@ -136,29 +134,24 @@ describe("GoogleVertexPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("google-vertex", {
|
||||
endpoint: {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
},
|
||||
options: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: { provider: { project: "config-project", location: "global" }, request: {} },
|
||||
},
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
}
|
||||
provider.options.aisdk.provider.project = "config-project"
|
||||
provider.options.aisdk.provider.location = "global"
|
||||
}),
|
||||
)
|
||||
expect(result.provider.options.aisdk.provider.project).toBe("config-project")
|
||||
expect(result.provider.options.aisdk.provider.location).toBe("global")
|
||||
expect(result.provider.endpoint).toEqual({
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
|
||||
expect(provider.options.aisdk.provider.project).toBe("config-project")
|
||||
expect(provider.options.aisdk.provider.location).toBe("global")
|
||||
expect(provider.endpoint).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global",
|
||||
@@ -180,19 +173,18 @@ describe("GoogleVertexPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(GoogleVertexPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("google-vertex", {
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { project: "config-project" }, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" }
|
||||
provider.options.aisdk.provider.project = "config-project"
|
||||
}),
|
||||
)
|
||||
expect(result.provider.options.aisdk.provider.project).toBe("config-project")
|
||||
expect(result.provider.options.aisdk.provider.location).toBe("us-central1")
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
|
||||
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 { expect } from "bun:test"
|
||||
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 { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
|
||||
|
||||
export const npmLayer = Layer.succeed(
|
||||
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>) {
|
||||
return new ProviderV2.Info({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { expectPluginRegistered, it, provider } from "./provider-helper"
|
||||
|
||||
describe("KiloPlugin", () => {
|
||||
@@ -18,73 +20,81 @@ describe("KiloPlugin", () => {
|
||||
it.effect("applies legacy referer headers only to kilo", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(KiloPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("kilo", {
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
|
||||
expect(result.provider.options.headers).toEqual({
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const kilo = provider("kilo", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
})
|
||||
catalog.provider.update(kilo.id, (draft) => {
|
||||
draft.endpoint = kilo.endpoint
|
||||
draft.options = kilo.options
|
||||
})
|
||||
catalog.provider.update(provider("openrouter").id, () => {})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"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", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
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/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect(result.provider.options.headers).not.toHaveProperty("http-referer")
|
||||
expect(result.provider.options.headers).not.toHaveProperty("x-title")
|
||||
expect(result.provider.options.headers).not.toHaveProperty("X-Source")
|
||||
expect(result.options.headers).not.toHaveProperty("http-referer")
|
||||
expect(result.options.headers).not.toHaveProperty("x-title")
|
||||
expect(result.options.headers).not.toHaveProperty("X-Source")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(KiloPlugin)
|
||||
const matchingID = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("kilo", {
|
||||
endpoint: { type: "aisdk", package: "not-kilo" },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const matchingPackage = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("custom-kilo", {
|
||||
endpoint: { type: "aisdk", package: "kilo" },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const kilo = provider("kilo", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
})
|
||||
catalog.provider.update(kilo.id, (draft) => {
|
||||
draft.endpoint = kilo.endpoint
|
||||
})
|
||||
const custom = provider("custom-kilo", {
|
||||
endpoint: { type: "aisdk", package: "kilo" },
|
||||
})
|
||||
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/",
|
||||
"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 { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { expectPluginRegistered, it, provider } from "./provider-helper"
|
||||
|
||||
describe("LLMGatewayPlugin", () => {
|
||||
@@ -18,46 +20,54 @@ describe("LLMGatewayPlugin", () => {
|
||||
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(LLMGatewayPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("llmgateway", {
|
||||
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const ignored = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("openrouter", {
|
||||
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
expect(result.provider.options.headers).toEqual({
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const llmgateway = provider("llmgateway", {
|
||||
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
})
|
||||
catalog.provider.update(llmgateway.id, (draft) => {
|
||||
draft.enabled = llmgateway.enabled
|
||||
draft.endpoint = llmgateway.endpoint
|
||||
draft.options = llmgateway.options
|
||||
})
|
||||
const openrouter = provider("openrouter", {
|
||||
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
|
||||
})
|
||||
catalog.provider.update(openrouter.id, (draft) => {
|
||||
draft.enabled = openrouter.enabled
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "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", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
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(result.provider.options.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { expectPluginRegistered, it, provider } from "./provider-helper"
|
||||
|
||||
describe("NvidiaPlugin", () => {
|
||||
@@ -18,45 +20,48 @@ describe("NvidiaPlugin", () => {
|
||||
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("nvidia", {
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
|
||||
expect(result.provider.options.headers).toEqual({
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const nvidia = provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
})
|
||||
catalog.provider.update(nvidia.id, (draft) => {
|
||||
draft.endpoint = nvidia.endpoint
|
||||
draft.options = nvidia.options
|
||||
})
|
||||
catalog.provider.update(provider("openrouter").id, () => {})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "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", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "test-provider", url: "http://localhost:8000/v1" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
})
|
||||
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/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
@@ -67,23 +72,25 @@ describe("NvidiaPlugin", () => {
|
||||
it.effect("preserves an explicit NVIDIA billing origin header", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(NvidiaPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("nvidia", {
|
||||
options: {
|
||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||
body: {},
|
||||
aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
|
||||
},
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
options: {
|
||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||
body: {},
|
||||
aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
|
||||
},
|
||||
})
|
||||
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/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
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 { 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", () => {
|
||||
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
|
||||
@@ -70,31 +72,39 @@ 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* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenAIPlugin)
|
||||
const normal = yield* plugin.trigger("model.update", {}, { model: model("openai", "gpt-5"), cancel: false })
|
||||
const filtered = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("openai", "gpt-5-chat-latest"), cancel: false },
|
||||
)
|
||||
expect(normal.cancel).toBe(false)
|
||||
expect(filtered.cancel).toBe(true)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
|
||||
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* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenAIPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("custom-openai", "gpt-5-chat-latest"), cancel: false },
|
||||
)
|
||||
expect(result.cancel).toBe(false)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("custom-openai")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
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" }))
|
||||
|
||||
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 }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
|
||||
const paid = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
|
||||
)
|
||||
expect(updated.provider.options.aisdk.provider.apiKey).toBe("public")
|
||||
expect(paid.cancel).toBe(true)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.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("paid"))).enabled).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -33,14 +37,19 @@ describe("OpencodePlugin", () => {
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
|
||||
const free = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("opencode", "free", { cost: cost(0) }), cancel: false },
|
||||
)
|
||||
expect(free.cancel).toBe(false)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const free = model("opencode", "free", { cost: cost(0) })
|
||||
catalog.model.update(item.id, free.id, (draft) => {
|
||||
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 }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
|
||||
const outputOnly = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("opencode", "output-only", { cost: cost(0, 1) }), cancel: false },
|
||||
)
|
||||
expect(outputOnly.cancel).toBe(false)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
|
||||
catalog.model.update(item.id, outputOnly.id, (draft) => {
|
||||
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" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
|
||||
const paid = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
|
||||
)
|
||||
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
|
||||
expect(paid.cancel).toBe(false)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
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" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const updated = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{ provider: provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }), cancel: false },
|
||||
)
|
||||
const paid = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
|
||||
)
|
||||
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
|
||||
expect(paid.cancel).toBe(false)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.env = [...item.env]
|
||||
})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -103,31 +123,30 @@ describe("OpencodePlugin", () => {
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const updated = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("opencode", {
|
||||
options: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: { apiKey: "configured" },
|
||||
request: {},
|
||||
},
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("opencode", {
|
||||
options: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: { apiKey: "configured" },
|
||||
request: {},
|
||||
},
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const paid = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
|
||||
)
|
||||
expect(updated.provider.options.aisdk.provider.apiKey).toBe("configured")
|
||||
expect(paid.cancel).toBe(false)
|
||||
},
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.options = item.options
|
||||
})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("configured")
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -136,19 +155,21 @@ describe("OpencodePlugin", () => {
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const updated = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{ provider: provider("opencode", { enabled: { via: "auth", service: "opencode" } }), cancel: false },
|
||||
)
|
||||
const paid = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
|
||||
)
|
||||
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
|
||||
expect(paid.cancel).toBe(false)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("opencode", { enabled: { via: "account", service: "opencode" } })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.enabled = item.enabled
|
||||
})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
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 }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
|
||||
const paid = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("openai", "paid", { cost: cost(1) }), cancel: false },
|
||||
)
|
||||
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
|
||||
expect(paid.cancel).toBe(false)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("openai")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("openai", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -175,18 +200,21 @@ describe("OpencodePlugin", () => {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.opencode
|
||||
|
||||
yield* catalog.provider.update(providerID, () => {})
|
||||
yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = cost(1, 1)
|
||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||
})
|
||||
yield* catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = cost(10, 10)
|
||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [...cost(1, 1)]
|
||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||
})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [...cost(10, 10)]
|
||||
model.time.released = DateTime.makeUnsafe(Date.now())
|
||||
})
|
||||
})
|
||||
|
||||
const selected = yield* catalog.model.small(providerID)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
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 { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { expectPluginRegistered, it, model, provider } from "./provider-helper"
|
||||
|
||||
describe("OpenRouterPlugin", () => {
|
||||
@@ -18,24 +21,27 @@ describe("OpenRouterPlugin", () => {
|
||||
it.effect("applies legacy referer headers only to openrouter", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenRouterPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("openrouter", {
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("nvidia"), cancel: false })
|
||||
expect(result.provider.options.headers).toEqual({
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const openrouter = provider("openrouter", {
|
||||
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
})
|
||||
catalog.provider.update(openrouter.id, (item) => {
|
||||
item.endpoint = openrouter.endpoint
|
||||
item.options = openrouter.options
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).options.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect(ignored.provider.options.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -67,39 +73,50 @@ describe("OpenRouterPlugin", () => {
|
||||
it.effect("filters OpenRouter's gpt-5 chat alias", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenRouterPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("openrouter", "openai/gpt-5-chat"), cancel: false },
|
||||
)
|
||||
const regular = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("openrouter", "openai/gpt-5"), cancel: false },
|
||||
)
|
||||
const ignored = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("openai", "openai/gpt-5-chat"), cancel: false },
|
||||
)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const openrouter = provider("openrouter", {
|
||||
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
||||
})
|
||||
catalog.provider.update(openrouter.id, (item) => {
|
||||
item.endpoint = openrouter.endpoint
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||
for (const item of [
|
||||
model("openrouter", "openai/gpt-5-chat"),
|
||||
model("openrouter", "openai/gpt-5"),
|
||||
model("openai", "openai/gpt-5-chat"),
|
||||
]) {
|
||||
catalog.model.update(item.providerID, item.id, () => {})
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.cancel).toBe(true)
|
||||
expect(regular.cancel).toBe(false)
|
||||
expect(ignored.cancel).toBe(false)
|
||||
expect(
|
||||
(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))).enabled,
|
||||
).toBe(false)
|
||||
expect(
|
||||
(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled,
|
||||
).toBe(true)
|
||||
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* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpenRouterPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"model.update",
|
||||
{},
|
||||
{ model: model("custom-openrouter", "gpt-5-chat-latest"), cancel: false },
|
||||
)
|
||||
expect(result.cancel).toBe(false)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
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 { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it, model, provider } from "./provider-helper"
|
||||
|
||||
describe("VercelPlugin", () => {
|
||||
it.effect("applies legacy lower-case referer headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(VercelPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("vercel", {
|
||||
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
expect(result.provider.options.headers).toEqual({
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("vercel", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/vercel" },
|
||||
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.options = item.options
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).toEqual({
|
||||
Existing: "1",
|
||||
"http-referer": "https://opencode.ai/",
|
||||
"x-title": "opencode",
|
||||
@@ -30,10 +34,19 @@ describe("VercelPlugin", () => {
|
||||
it.effect("does not add legacy upper-case referer headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(VercelPlugin)
|
||||
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("vercel"), cancel: false })
|
||||
expect(result.provider.options.headers).not.toHaveProperty("HTTP-Referer")
|
||||
expect(result.provider.options.headers).not.toHaveProperty("X-Title")
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
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 +67,11 @@ describe("VercelPlugin", () => {
|
||||
it.effect("ignores non-Vercel providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(VercelPlugin)
|
||||
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("gateway"), cancel: false })
|
||||
expect(result.provider.options.headers).toEqual({})
|
||||
const load = yield* catalog.loader()
|
||||
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 { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { expectPluginRegistered, it, provider } from "./provider-helper"
|
||||
|
||||
describe("ZenmuxPlugin", () => {
|
||||
@@ -18,30 +20,41 @@ describe("ZenmuxPlugin", () => {
|
||||
it.effect("applies the exact legacy Zenmux headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(ZenmuxPlugin)
|
||||
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("zenmux"), cancel: false })
|
||||
expect(result.provider.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
|
||||
expect(Object.keys(result.provider.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
|
||||
expect(result.cancel).toBe(false)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
})
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(ZenmuxPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("zenmux", {
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
})
|
||||
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",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
@@ -52,23 +65,25 @@ describe("ZenmuxPlugin", () => {
|
||||
it.effect("lets configured Zenmux legacy headers override defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(ZenmuxPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("zenmux", {
|
||||
options: {
|
||||
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
|
||||
body: {},
|
||||
aisdk: { provider: {}, request: {} },
|
||||
},
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
options: {
|
||||
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
|
||||
body: {},
|
||||
aisdk: { provider: {}, request: {} },
|
||||
},
|
||||
})
|
||||
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/",
|
||||
"X-Title": "custom-title",
|
||||
})
|
||||
@@ -78,23 +93,23 @@ describe("ZenmuxPlugin", () => {
|
||||
it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(ZenmuxPlugin)
|
||||
const ignored = yield* plugin.trigger(
|
||||
"provider.update",
|
||||
{},
|
||||
{
|
||||
provider: provider("openrouter", {
|
||||
options: {
|
||||
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
|
||||
body: {},
|
||||
aisdk: { provider: {}, request: {} },
|
||||
},
|
||||
}),
|
||||
cancel: false,
|
||||
},
|
||||
)
|
||||
const load = yield* catalog.loader()
|
||||
yield* load((catalog) => {
|
||||
const item = provider("openrouter", {
|
||||
options: {
|
||||
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
|
||||
body: {},
|
||||
aisdk: { provider: {}, request: {} },
|
||||
},
|
||||
})
|
||||
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/",
|
||||
"X-Title": "custom-title",
|
||||
})
|
||||
|
||||
@@ -127,6 +127,7 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
|
||||
|
||||
const OpenAIChatDelta = Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
reasoning_content: optionalNull(Schema.String),
|
||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||
})
|
||||
|
||||
@@ -324,6 +325,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
|
||||
let lifecycle = state.lifecycle
|
||||
|
||||
if (delta?.reasoning_content)
|
||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
||||
|
||||
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
|
||||
for (const tool of toolDeltas) {
|
||||
|
||||
@@ -413,6 +413,29 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste
|
||||
]
|
||||
}
|
||||
|
||||
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, event.item_id ?? "reasoning-0", event.delta),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const onReasoningDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, event.item_id ?? "reasoning-0"),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
|
||||
@@ -523,6 +546,18 @@ const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult =>
|
||||
|
||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
||||
if (
|
||||
event.type === "response.reasoning_text.delta" ||
|
||||
event.type === "response.reasoning_summary.delta" ||
|
||||
event.type === "response.reasoning_summary_text.delta"
|
||||
)
|
||||
return Effect.succeed(onReasoningDelta(state, event))
|
||||
if (
|
||||
event.type === "response.reasoning_text.done" ||
|
||||
event.type === "response.reasoning_summary.done" ||
|
||||
event.type === "response.reasoning_summary_text.done"
|
||||
)
|
||||
return Effect.succeed(onReasoningDone(state, event))
|
||||
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
|
||||
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||
|
||||
Vendored
+32
File diff suppressed because one or more lines are too long
@@ -83,6 +83,7 @@ describeRecordedGoldenScenarios([
|
||||
tags: ["flagship"],
|
||||
scenarios: [
|
||||
{ id: "text", temperature: false },
|
||||
{ id: "reasoning", temperature: false },
|
||||
{ id: "tool-call", temperature: false },
|
||||
{ id: "tool-loop", temperature: false },
|
||||
],
|
||||
|
||||
@@ -260,6 +260,32 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses OpenAI-compatible reasoning content deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{ choices: [{ delta: { reasoning_content: "thinking" } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
)
|
||||
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "reasoning-start", id: "reasoning-0" },
|
||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||
{ type: "text-start", id: "text-0" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "reasoning-end", id: "reasoning-0" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -118,6 +118,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("fails immediately when WebSocket is already closed", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* WebSocketExecutor.fromWebSocket(
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch.
|
||||
{ readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
|
||||
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
|
||||
).pipe(Effect.flip)
|
||||
@@ -352,6 +353,33 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses reasoning summary stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1" },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
)
|
||||
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "reasoning-start", id: "rs_1" },
|
||||
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "reasoning-end", id: "rs_1" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed function call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { describe, type TestOptions } from "bun:test"
|
||||
import { describe } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import type { Model } from "../src"
|
||||
import { goldenScenarioTags, runGoldenScenario, type GoldenScenarioID } from "./recorded-scenarios"
|
||||
@@ -17,7 +17,7 @@ type ScenarioInput =
|
||||
readonly tags?: ReadonlyArray<string>
|
||||
readonly maxTokens?: number
|
||||
readonly temperature?: number | false
|
||||
readonly timeout?: number | TestOptions
|
||||
readonly timeout?: number
|
||||
}
|
||||
|
||||
type TargetInput = {
|
||||
@@ -38,6 +38,7 @@ const scenarioInput = (input: ScenarioInput) => (typeof input === "string" ? { i
|
||||
const scenarioTitle = (id: GoldenScenarioID) => {
|
||||
if (id === "text") return "streams text"
|
||||
if (id === "tool-call") return "streams tool call"
|
||||
if (id === "reasoning") return "uses reasoning"
|
||||
if (id === "image") return "reads image text"
|
||||
return "drives a tool loop"
|
||||
}
|
||||
|
||||
@@ -143,6 +143,25 @@ export const imageRequest = (input: {
|
||||
: { maxTokens: input.maxTokens ?? 20, temperature: input.temperature ?? 0 },
|
||||
})
|
||||
|
||||
export const reasoningRequest = (input: {
|
||||
readonly id: string
|
||||
readonly model: Model
|
||||
readonly maxTokens?: number
|
||||
readonly temperature?: number | false
|
||||
}) =>
|
||||
LLM.request({
|
||||
id: input.id,
|
||||
model: input.model,
|
||||
system: "Show concise reasoning when the provider supports visible reasoning summaries.",
|
||||
prompt: "Think briefly, then reply exactly with: Hello!",
|
||||
cache: "none",
|
||||
providerOptions: { openai: { reasoningEffort: "low", reasoningSummary: "auto" } },
|
||||
generation:
|
||||
input.temperature === false
|
||||
? { maxTokens: input.maxTokens ?? 120 }
|
||||
: { maxTokens: input.maxTokens ?? 120, temperature: input.temperature ?? 0 },
|
||||
})
|
||||
|
||||
export const runWeatherToolLoop = (request: LLMRequest) =>
|
||||
LLMClient.stream({
|
||||
request,
|
||||
@@ -193,7 +212,7 @@ export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) =>
|
||||
expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/)
|
||||
}
|
||||
|
||||
export type GoldenScenarioID = "text" | "tool-call" | "tool-loop" | "image"
|
||||
export type GoldenScenarioID = "text" | "tool-call" | "tool-loop" | "image" | "reasoning"
|
||||
|
||||
export interface GoldenScenarioContext {
|
||||
readonly id: string
|
||||
@@ -215,6 +234,7 @@ export const goldenScenarioTags = (id: GoldenScenarioID) => {
|
||||
if (id === "text") return ["text", "golden"]
|
||||
if (id === "tool-call") return ["tool", "tool-call", "golden"]
|
||||
if (id === "image") return ["media", "image", "vision", "golden"]
|
||||
if (id === "reasoning") return ["reasoning", "golden"]
|
||||
return ["tool", "tool-loop", "golden"]
|
||||
}
|
||||
|
||||
@@ -264,6 +284,21 @@ export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioC
|
||||
return
|
||||
}
|
||||
|
||||
if (id === "reasoning") {
|
||||
const response = yield* generate(
|
||||
reasoningRequest({
|
||||
id: context.id,
|
||||
model: context.model,
|
||||
maxTokens: context.maxTokens ?? 120,
|
||||
temperature: context.temperature,
|
||||
}),
|
||||
)
|
||||
expect(response.text.trim()).toMatch(/^Hello!?$/)
|
||||
expect(response.usage?.reasoningTokens ?? 0).toBeGreaterThan(0)
|
||||
expectFinish(response.events, "stop")
|
||||
return
|
||||
}
|
||||
|
||||
expectGoldenWeatherToolLoop(
|
||||
yield* runWeatherToolLoop(
|
||||
goldenWeatherToolLoopRequest({
|
||||
@@ -293,7 +328,7 @@ const usageSummary = (usage: LLMResponse["usage"] | undefined) => {
|
||||
const pushText = (summary: Array<Record<string, unknown>>, type: "text" | "reasoning", value: string) => {
|
||||
const last = summary.at(-1)
|
||||
if (last?.type === type) {
|
||||
last.value = `${last.value ?? ""}${value}`
|
||||
last.value = `${typeof last.value === "string" ? last.value : ""}${value}`
|
||||
return
|
||||
}
|
||||
summary.push({ type, value })
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
|
||||
import { effectCmd } from "../../effect-cmd"
|
||||
|
||||
const Runtime = Layer.mergeAll(LocationServiceMap.layer)
|
||||
|
||||
export const V2Command = effectCmd({
|
||||
command: "v2",
|
||||
describe: "debug v2 catalog and built-in plugins",
|
||||
instance: false,
|
||||
handler: Effect.fn("Cli.debug.v2")(
|
||||
function* () {
|
||||
handler: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* PluginBoot.Service.use((service) => service.wait())
|
||||
const catalog = yield* Catalog.Service
|
||||
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)
|
||||
},
|
||||
Effect.provide(
|
||||
LocationServiceMap.get({
|
||||
directory: process.cwd(),
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.withSpan("Cli.debug.v2"),
|
||||
Effect.provide(
|
||||
LocationServiceMap.get({
|
||||
directory: process.cwd(),
|
||||
}),
|
||||
),
|
||||
Effect.provide(LocationServiceMap.layer),
|
||||
),
|
||||
Effect.provide(Runtime),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -458,7 +458,7 @@ export const RunCommand = effectCmd({
|
||||
const name = title()
|
||||
const result = await sdk.session.create({
|
||||
title: name,
|
||||
permission: rules,
|
||||
permission: [...rules],
|
||||
})
|
||||
const id = result.data?.id
|
||||
if (!id) {
|
||||
@@ -501,7 +501,7 @@ export const RunCommand = effectCmd({
|
||||
variant: input.variant,
|
||||
}
|
||||
: undefined,
|
||||
permission: rules,
|
||||
permission: [...rules],
|
||||
})
|
||||
const id = result.data?.id
|
||||
if (!id) {
|
||||
|
||||
@@ -416,7 +416,7 @@ export function RunQuestionBody(props: {
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!info()?.multiple}>
|
||||
<text fg={props.theme.success}>{hit() ? "✓" : ""}</text>
|
||||
<text fg={props.theme.success}>{hit() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingLeft={3}>
|
||||
@@ -466,7 +466,7 @@ export function RunQuestionBody(props: {
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!info()?.multiple}>
|
||||
<text fg={props.theme.success}>{picked() ? "✓" : ""}</text>
|
||||
<text fg={props.theme.success}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show
|
||||
|
||||
@@ -375,7 +375,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
|
||||
<text fg={theme.success}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -408,7 +408,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
</box>
|
||||
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
|
||||
<text fg={theme.success}>{customPicked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={store.editing}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import "@opencode-ai/core/account"
|
||||
import "@opencode-ai/core/catalog"
|
||||
import "@opencode-ai/core/session-event"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
@@ -81,7 +82,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
Layer.provide(ProjectBus.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -1,15 +1 @@
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
|
||||
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: "*" }
|
||||
}
|
||||
export { evaluate } from "@opencode-ai/core/permission"
|
||||
|
||||
@@ -8,15 +8,15 @@ import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database } from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
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 os from "os"
|
||||
import { evaluate as evalRule } from "./evaluate"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { PermissionID } from "./schema"
|
||||
|
||||
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 const Rule = Schema.Struct({
|
||||
@@ -26,7 +26,7 @@ export const Rule = Schema.Struct({
|
||||
}).annotate({ identifier: "PermissionRule" })
|
||||
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 class Request extends Schema.Class<Request>("PermissionRequest")({
|
||||
@@ -122,11 +122,11 @@ interface PendingEntry {
|
||||
|
||||
interface State {
|
||||
pending: Map<PermissionID, PendingEntry>
|
||||
approved: Ruleset
|
||||
approved: 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") {}
|
||||
@@ -142,7 +142,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
const state = {
|
||||
pending: new Map<PermissionID, PendingEntry>(),
|
||||
approved: row?.data ?? [],
|
||||
approved: [...(row?.data ?? [])],
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -271,7 +271,7 @@ function expand(pattern: string): string {
|
||||
}
|
||||
|
||||
export function fromConfig(permission: ConfigPermission.Info) {
|
||||
const ruleset: Ruleset = []
|
||||
const ruleset: Rule[] = []
|
||||
for (const [key, value] of Object.entries(permission)) {
|
||||
if (typeof value === "string") {
|
||||
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||
@@ -284,21 +284,12 @@ export function fromConfig(permission: ConfigPermission.Info) {
|
||||
return ruleset
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||
return rulesets.flat()
|
||||
export function merge(...rulesets: Ruleset[]): Rule[] {
|
||||
return [...PermissionV2.merge(...rulesets)]
|
||||
}
|
||||
|
||||
const EDIT_TOOLS = ["edit", "write", "apply_patch"]
|
||||
|
||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
const result = new Set<string>()
|
||||
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
|
||||
return PermissionV2.disabled(tools, ruleset)
|
||||
}
|
||||
|
||||
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({})
|
||||
|
||||
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({})),
|
||||
)
|
||||
const payload = decoded
|
||||
? {
|
||||
...decoded,
|
||||
permission: decoded.permission ? [...decoded.permission] : undefined,
|
||||
}
|
||||
: decoded
|
||||
return yield* create({ payload })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,41 +1,34 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Context, Effect, Layer, Record } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema } from "ai"
|
||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai"
|
||||
import type { LLMEvent } from "@opencode-ai/llm"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||
import type { LLMClientService } from "@opencode-ai/llm/route"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { Bus } from "@/bus"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
import { LLMAISDK } from "./llm/ai-sdk"
|
||||
import { LLMNativeRuntime } from "./llm/native-runtime"
|
||||
import { LLMRequestPrep } from "./llm/request"
|
||||
|
||||
const log = Log.create({ service: "llm" })
|
||||
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
||||
|
||||
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
|
||||
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
|
||||
mergeDeep(target, source ?? {}) as Record<string, any>
|
||||
|
||||
export type StreamInput = {
|
||||
user: MessageV2.User
|
||||
sessionID: string
|
||||
@@ -106,123 +99,15 @@ const live: Layer.Layer<
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
// TODO: move this to a proper hook
|
||||
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
|
||||
|
||||
const system: string[] = []
|
||||
system.push(
|
||||
[
|
||||
// use agent prompt otherwise provider prompt
|
||||
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
|
||||
// any custom prompt passed into this call
|
||||
...input.system,
|
||||
// any custom prompt from last user message
|
||||
...(input.user.system ? [input.user.system] : []),
|
||||
]
|
||||
.filter((x) => x)
|
||||
.join("\n"),
|
||||
)
|
||||
|
||||
const header = system[0]
|
||||
yield* plugin.trigger(
|
||||
"experimental.chat.system.transform",
|
||||
{ sessionID: input.sessionID, model: input.model },
|
||||
{ system },
|
||||
)
|
||||
// rejoin to maintain 2-part structure for caching if header unchanged
|
||||
if (system.length > 2 && system[0] === header) {
|
||||
const rest = system.slice(1)
|
||||
system.length = 0
|
||||
system.push(header, rest.join("\n"))
|
||||
}
|
||||
|
||||
const variant =
|
||||
!input.small && input.model.variants && input.user.model.variant
|
||||
? input.model.variants[input.user.model.variant]
|
||||
: {}
|
||||
const base = input.small
|
||||
? ProviderTransform.smallOptions(input.model)
|
||||
: ProviderTransform.options({
|
||||
model: input.model,
|
||||
sessionID: input.sessionID,
|
||||
providerOptions: item.options,
|
||||
})
|
||||
const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)
|
||||
if (isOpenaiOauth) {
|
||||
options.instructions = system.join("\n")
|
||||
}
|
||||
|
||||
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
|
||||
const messages = isOpenaiOauth
|
||||
? input.messages
|
||||
: isWorkflow
|
||||
? input.messages
|
||||
: [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...input.messages,
|
||||
]
|
||||
|
||||
const params = yield* plugin.trigger(
|
||||
"chat.params",
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent.name,
|
||||
model: input.model,
|
||||
provider: item,
|
||||
message: input.user,
|
||||
},
|
||||
{
|
||||
temperature: input.model.capabilities.temperature
|
||||
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
|
||||
: undefined,
|
||||
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
|
||||
topK: ProviderTransform.topK(input.model),
|
||||
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, flags.outputTokenMax),
|
||||
options,
|
||||
},
|
||||
)
|
||||
|
||||
const { headers } = yield* plugin.trigger(
|
||||
"chat.headers",
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent.name,
|
||||
model: input.model,
|
||||
provider: item,
|
||||
message: input.user,
|
||||
},
|
||||
{
|
||||
headers: {},
|
||||
},
|
||||
)
|
||||
|
||||
const tools = resolveTools(input)
|
||||
|
||||
// GitHub Copilot may require the tools parameter when message history contains
|
||||
// tool calls but no tools are active (e.g. compaction). Inject a stub tool that
|
||||
// is never meant to be invoked. LiteLLM-backed providers are excluded.
|
||||
if (
|
||||
input.model.providerID.includes("github-copilot") &&
|
||||
Object.keys(tools).length === 0 &&
|
||||
hasToolCalls(input.messages)
|
||||
) {
|
||||
tools["_noop"] = aiTool({
|
||||
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
||||
inputSchema: jsonSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
reason: { type: "string", description: "Unused" },
|
||||
},
|
||||
}),
|
||||
execute: async () => ({ output: "", title: "", metadata: {} }),
|
||||
})
|
||||
}
|
||||
const sortedTools = Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b)))
|
||||
const prepared = yield* LLMRequestPrep.prepare({
|
||||
...input,
|
||||
provider: item,
|
||||
auth: info,
|
||||
plugin,
|
||||
flags,
|
||||
isWorkflow,
|
||||
})
|
||||
|
||||
// Wire up toolExecutor for DWS workflow models so that tool calls
|
||||
// from the workflow service are executed via opencode's tool system
|
||||
@@ -234,9 +119,9 @@ const live: Layer.Layer<
|
||||
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
|
||||
}
|
||||
workflowModel.sessionID = input.sessionID
|
||||
workflowModel.systemPrompt = system.join("\n")
|
||||
workflowModel.systemPrompt = prepared.system.join("\n")
|
||||
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
||||
const t = sortedTools[toolName]
|
||||
const t = prepared.tools[toolName]
|
||||
if (!t || !t.execute) {
|
||||
return { result: "", error: `Unknown tool: ${toolName}` }
|
||||
}
|
||||
@@ -258,7 +143,7 @@ const live: Layer.Layer<
|
||||
}
|
||||
|
||||
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
|
||||
workflowModel.sessionPreapprovedTools = Object.keys(sortedTools).filter((name) => {
|
||||
workflowModel.sessionPreapprovedTools = Object.keys(prepared.tools).filter((name) => {
|
||||
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
|
||||
return !match || match.action !== "ask"
|
||||
})
|
||||
@@ -327,28 +212,6 @@ const live: Layer.Layer<
|
||||
})
|
||||
: undefined
|
||||
|
||||
const opencodeProjectID = input.model.providerID.startsWith("opencode")
|
||||
? (yield* InstanceState.context).project.id
|
||||
: undefined
|
||||
|
||||
const requestHeaders = {
|
||||
...(input.model.providerID.startsWith("opencode")
|
||||
? {
|
||||
...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
|
||||
"x-opencode-session": input.sessionID,
|
||||
"x-opencode-request": input.user.id,
|
||||
"x-opencode-client": flags.client,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}
|
||||
: {
|
||||
"x-session-affinity": input.sessionID,
|
||||
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}),
|
||||
...input.model.headers,
|
||||
...headers,
|
||||
}
|
||||
|
||||
// Runtime seam: native is an opt-in adapter over @opencode-ai/llm. It
|
||||
// either returns a ready LLMEvent stream or a concrete fallback reason.
|
||||
if (flags.experimentalNativeLlm) {
|
||||
@@ -357,17 +220,8 @@ const live: Layer.Layer<
|
||||
provider: item,
|
||||
auth: info,
|
||||
llmClient,
|
||||
isOpenaiOauth,
|
||||
system,
|
||||
messages,
|
||||
tools: sortedTools,
|
||||
request: prepared,
|
||||
toolChoice: input.toolChoice,
|
||||
temperature: params.temperature,
|
||||
topP: params.topP,
|
||||
topK: params.topK,
|
||||
maxOutputTokens: params.maxOutputTokens,
|
||||
providerOptions: params.options,
|
||||
headers: requestHeaders,
|
||||
abort: input.abort,
|
||||
})
|
||||
if (native.type === "supported") {
|
||||
@@ -413,7 +267,7 @@ const live: Layer.Layer<
|
||||
},
|
||||
async experimental_repairToolCall(failed) {
|
||||
const lower = failed.toolCall.toolName.toLowerCase()
|
||||
if (lower !== failed.toolCall.toolName && sortedTools[lower]) {
|
||||
if (lower !== failed.toolCall.toolName && prepared.tools[lower]) {
|
||||
l.info("repairing tool call", {
|
||||
tool: failed.toolCall.toolName,
|
||||
repaired: lower,
|
||||
@@ -432,18 +286,18 @@ const live: Layer.Layer<
|
||||
toolName: "invalid",
|
||||
}
|
||||
},
|
||||
temperature: params.temperature,
|
||||
topP: params.topP,
|
||||
topK: params.topK,
|
||||
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
|
||||
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
|
||||
tools: sortedTools,
|
||||
temperature: prepared.params.temperature,
|
||||
topP: prepared.params.topP,
|
||||
topK: prepared.params.topK,
|
||||
providerOptions: ProviderTransform.providerOptions(input.model, prepared.params.options),
|
||||
activeTools: Object.keys(prepared.tools).filter((x) => x !== "invalid"),
|
||||
tools: prepared.tools,
|
||||
toolChoice: input.toolChoice,
|
||||
maxOutputTokens: params.maxOutputTokens,
|
||||
maxOutputTokens: prepared.params.maxOutputTokens,
|
||||
abortSignal: input.abort,
|
||||
headers: requestHeaders,
|
||||
headers: prepared.headers,
|
||||
maxRetries: input.retries ?? 0,
|
||||
messages,
|
||||
messages: prepared.messages,
|
||||
model: wrapLanguageModel({
|
||||
model: language,
|
||||
middleware: [
|
||||
@@ -452,7 +306,11 @@ const live: Layer.Layer<
|
||||
async transformParams(args) {
|
||||
if (args.type === "stream") {
|
||||
// @ts-expect-error
|
||||
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
|
||||
args.params.prompt = ProviderTransform.message(
|
||||
args.params.prompt,
|
||||
input.model,
|
||||
prepared.messageTransformOptions,
|
||||
)
|
||||
}
|
||||
return args.params
|
||||
},
|
||||
@@ -517,24 +375,6 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
),
|
||||
)
|
||||
|
||||
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
||||
const disabled = Permission.disabled(
|
||||
Object.keys(input.tools),
|
||||
Permission.merge(input.agent.permission, input.permission ?? []),
|
||||
)
|
||||
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
|
||||
}
|
||||
|
||||
// Check if messages contain any tool-call content
|
||||
// Used to determine if a dummy tool should be added (GitHub Copilot only; see stream()).
|
||||
export function hasToolCalls(messages: ModelMessage[]): boolean {
|
||||
for (const msg of messages) {
|
||||
if (!Array.isArray(msg.content)) continue
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "tool-call" || part.type === "tool-result") return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
export const hasToolCalls = LLMRequestPrep.hasToolCalls
|
||||
|
||||
export * as LLM from "./llm"
|
||||
|
||||
@@ -12,26 +12,25 @@ import {
|
||||
import type { ModelMessage } from "ai"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { isRecord } from "@/util/record"
|
||||
import type { Prepared as PreparedRequest } from "./request"
|
||||
|
||||
type ToolInput = {
|
||||
readonly description?: string
|
||||
readonly inputSchema?: unknown
|
||||
}
|
||||
|
||||
export type RequestInput = {
|
||||
type GenerationInput = Omit<PreparedRequest["params"], "options">
|
||||
|
||||
export type RequestInput = GenerationInput & {
|
||||
readonly model: Provider.Model
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly system?: readonly string[]
|
||||
readonly messages: readonly ModelMessage[]
|
||||
readonly system?: PreparedRequest["system"]
|
||||
readonly messages: PreparedRequest["messages"]
|
||||
readonly tools?: Record<string, ToolInput>
|
||||
readonly toolChoice?: "auto" | "required" | "none"
|
||||
readonly temperature?: number
|
||||
readonly topP?: number
|
||||
readonly topK?: number
|
||||
readonly maxOutputTokens?: number
|
||||
readonly providerOptions?: LLMRequest["providerOptions"]
|
||||
readonly headers?: Record<string, string>
|
||||
readonly headers?: PreparedRequest["headers"]
|
||||
}
|
||||
|
||||
const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { Provider } from "@/provider/provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { asSchema, type ModelMessage, type Tool } from "ai"
|
||||
import { asSchema, type Tool } from "ai"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
|
||||
import type { LLMClientShape } from "@opencode-ai/llm/route"
|
||||
import { LLMNative } from "./native-request"
|
||||
import type { Prepared as PreparedRequest } from "./request"
|
||||
|
||||
export type RuntimeStatus =
|
||||
| { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string }
|
||||
@@ -22,17 +23,13 @@ type StreamInput = {
|
||||
readonly provider: Provider.Info
|
||||
readonly auth: Auth.Info | undefined
|
||||
readonly llmClient: LLMClientShape
|
||||
readonly isOpenaiOauth: boolean
|
||||
readonly system: string[]
|
||||
readonly messages: ModelMessage[]
|
||||
readonly tools: Record<string, Tool>
|
||||
readonly request: PreparedRequest
|
||||
readonly toolChoice?: "auto" | "required" | "none"
|
||||
readonly temperature?: number
|
||||
readonly topP?: number
|
||||
readonly topK?: number
|
||||
readonly maxOutputTokens?: number
|
||||
readonly providerOptions?: Record<string, any>
|
||||
readonly headers: Record<string, string>
|
||||
readonly abort: AbortSignal
|
||||
}
|
||||
|
||||
type ToolContext = {
|
||||
readonly messages: PreparedRequest["messages"]
|
||||
readonly abort: AbortSignal
|
||||
}
|
||||
|
||||
@@ -68,17 +65,18 @@ export function stream(input: StreamInput): StreamResult {
|
||||
model: input.model,
|
||||
apiKey: current.apiKey,
|
||||
baseURL: current.baseURL,
|
||||
system: input.isOpenaiOauth ? input.system : [],
|
||||
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),
|
||||
system: [],
|
||||
messages: ProviderTransform.message(input.request.messages, input.model, input.request.params.options),
|
||||
tools: input.request.tools,
|
||||
toolChoice: input.toolChoice,
|
||||
temperature: input.temperature,
|
||||
topP: input.topP,
|
||||
topK: input.topK,
|
||||
maxOutputTokens: input.maxOutputTokens,
|
||||
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
|
||||
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
|
||||
temperature: input.request.params.temperature,
|
||||
topP: input.request.params.topP,
|
||||
topK: input.request.params.topK,
|
||||
maxOutputTokens: input.request.params.maxOutputTokens,
|
||||
providerOptions: ProviderTransform.providerOptions(input.model, input.request.params.options),
|
||||
headers: { ...providerHeaders(input.provider.options.headers), ...input.request.headers },
|
||||
}),
|
||||
tools: nativeTools(input.tools, input),
|
||||
tools: nativeTools(input.request.tools, { messages: input.request.messages, abort: input.abort }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -97,7 +95,7 @@ function nativeSchema(value: unknown): JsonSchema {
|
||||
return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema
|
||||
}
|
||||
|
||||
export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput, "messages" | "abort">) {
|
||||
export function nativeTools(tools: Record<string, Tool>, input: ToolContext) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(tools).map(([name, item]) => [
|
||||
name,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { Auth } from "@/auth"
|
||||
import type { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Permission } from "@/permission"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import type { MessageV2 } from "../message-v2"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { SystemPrompt } from "../system"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Effect, Record } from "effect"
|
||||
import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai"
|
||||
import type { Plugin } from "@/plugin"
|
||||
import { mergeDeep } from "remeda"
|
||||
|
||||
const USER_AGENT = `opencode/${InstallationVersion}`
|
||||
|
||||
type PrepareInput = {
|
||||
readonly user: MessageV2.User
|
||||
readonly sessionID: string
|
||||
readonly parentSessionID?: string
|
||||
readonly model: Provider.Model
|
||||
readonly agent: Agent.Info
|
||||
readonly permission?: Permission.Ruleset
|
||||
readonly system: string[]
|
||||
readonly messages: ModelMessage[]
|
||||
readonly small?: boolean
|
||||
readonly tools: Record<string, Tool>
|
||||
readonly provider: Provider.Info
|
||||
readonly auth: Auth.Info | undefined
|
||||
readonly plugin: Plugin.Interface
|
||||
readonly flags: RuntimeFlags.Info
|
||||
readonly isWorkflow: boolean
|
||||
}
|
||||
|
||||
export type Prepared = {
|
||||
readonly system: string[]
|
||||
readonly messages: ModelMessage[]
|
||||
readonly tools: Record<string, Tool>
|
||||
readonly params: {
|
||||
readonly temperature?: number
|
||||
readonly topP?: number
|
||||
readonly topK?: number
|
||||
readonly maxOutputTokens?: number
|
||||
readonly options: Record<string, any>
|
||||
}
|
||||
readonly messageTransformOptions: Record<string, any>
|
||||
readonly headers: Record<string, string>
|
||||
}
|
||||
|
||||
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
|
||||
mergeDeep(target, source ?? {}) as Record<string, any>
|
||||
|
||||
export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: PrepareInput) {
|
||||
const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth"
|
||||
const system = [
|
||||
[
|
||||
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
|
||||
...input.system,
|
||||
...(input.user.system ? [input.user.system] : []),
|
||||
]
|
||||
.filter((x) => x)
|
||||
.join("\n"),
|
||||
]
|
||||
|
||||
const header = system[0]
|
||||
yield* input.plugin.trigger(
|
||||
"experimental.chat.system.transform",
|
||||
{ sessionID: input.sessionID, model: input.model },
|
||||
{ system },
|
||||
)
|
||||
if (system.length > 2 && system[0] === header) {
|
||||
const rest = system.slice(1)
|
||||
system.length = 0
|
||||
system.push(header, rest.join("\n"))
|
||||
}
|
||||
|
||||
const variant =
|
||||
!input.small && input.model.variants && input.user.model.variant
|
||||
? input.model.variants[input.user.model.variant]
|
||||
: {}
|
||||
const base = input.small
|
||||
? ProviderTransform.smallOptions(input.model)
|
||||
: ProviderTransform.options({
|
||||
model: input.model,
|
||||
sessionID: input.sessionID,
|
||||
providerOptions: input.provider.options,
|
||||
})
|
||||
const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)
|
||||
if (isOpenaiOauth) options.instructions = system.join("\n")
|
||||
|
||||
const messages =
|
||||
isOpenaiOauth || input.isWorkflow
|
||||
? input.messages
|
||||
: [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...input.messages,
|
||||
]
|
||||
|
||||
const params = yield* input.plugin.trigger(
|
||||
"chat.params",
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent.name,
|
||||
model: input.model,
|
||||
provider: input.provider,
|
||||
message: input.user,
|
||||
},
|
||||
{
|
||||
temperature: input.model.capabilities.temperature
|
||||
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
|
||||
: undefined,
|
||||
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
|
||||
topK: ProviderTransform.topK(input.model),
|
||||
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, input.flags.outputTokenMax),
|
||||
options,
|
||||
},
|
||||
)
|
||||
|
||||
const { headers: pluginHeaders } = yield* input.plugin.trigger(
|
||||
"chat.headers",
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent.name,
|
||||
model: input.model,
|
||||
provider: input.provider,
|
||||
message: input.user,
|
||||
},
|
||||
{
|
||||
headers: {},
|
||||
},
|
||||
)
|
||||
|
||||
const tools = resolveTools(input)
|
||||
if (
|
||||
input.model.providerID.includes("github-copilot") &&
|
||||
Object.keys(tools).length === 0 &&
|
||||
hasToolCalls(input.messages)
|
||||
) {
|
||||
// Copilot needs a tools field when replaying prior tool calls, even if no tools are currently enabled.
|
||||
tools["_noop"] = aiTool({
|
||||
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
||||
inputSchema: jsonSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
reason: { type: "string", description: "Unused" },
|
||||
},
|
||||
}),
|
||||
execute: async () => ({ output: "", title: "", metadata: {} }),
|
||||
})
|
||||
}
|
||||
|
||||
const opencodeProjectID = input.model.providerID.startsWith("opencode")
|
||||
? (yield* InstanceState.context).project.id
|
||||
: undefined
|
||||
|
||||
return {
|
||||
system,
|
||||
messages,
|
||||
tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))),
|
||||
params,
|
||||
messageTransformOptions: options,
|
||||
headers: {
|
||||
...(input.model.providerID.startsWith("opencode")
|
||||
? {
|
||||
...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
|
||||
"x-opencode-session": input.sessionID,
|
||||
"x-opencode-request": input.user.id,
|
||||
"x-opencode-client": input.flags.client,
|
||||
"User-Agent": USER_AGENT,
|
||||
}
|
||||
: {
|
||||
"x-session-affinity": input.sessionID,
|
||||
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
||||
"User-Agent": USER_AGENT,
|
||||
}),
|
||||
...input.model.headers,
|
||||
...pluginHeaders,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
|
||||
const disabled = Permission.disabled(
|
||||
Object.keys(input.tools),
|
||||
Permission.merge(input.agent.permission, input.permission ?? []),
|
||||
)
|
||||
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
|
||||
}
|
||||
|
||||
export function hasToolCalls(messages: ModelMessage[]): boolean {
|
||||
for (const msg of messages) {
|
||||
if (!Array.isArray(msg.content)) continue
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "tool-call" || part.type === "tool-result") return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export * as LLMRequestPrep from "./request"
|
||||
@@ -1216,7 +1216,7 @@ export const layer = Layer.effect(
|
||||
const message = yield* createUserMessage(input)
|
||||
yield* sessions.touch(input.sessionID)
|
||||
|
||||
const permissions: Permission.Ruleset = []
|
||||
const permissions: Permission.Rule[] = []
|
||||
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
||||
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export function fromRow(row: SessionRow): Info {
|
||||
},
|
||||
share,
|
||||
revert,
|
||||
permission: row.permission ?? undefined,
|
||||
permission: row.permission ? [...row.permission] : undefined,
|
||||
time: {
|
||||
created: row.time_created,
|
||||
updated: row.time_updated,
|
||||
@@ -542,7 +542,7 @@ export const layer: Layer.Layer<
|
||||
title: input.title ?? createDefaultTitle(!!input.parentID),
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
permission: input.permission,
|
||||
permission: input.permission ? [...input.permission] : undefined,
|
||||
cost: 0,
|
||||
tokens: EmptyTokens,
|
||||
time: {
|
||||
@@ -734,7 +734,7 @@ export const layer: Layer.Layer<
|
||||
sessionID: SessionID
|
||||
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: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SyncEvent } from "@/sync"
|
||||
import { Effect, Layer, Scope, Context } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import * as ShareNext from "./share-next"
|
||||
import { ShareNext } from "./share-next"
|
||||
|
||||
export interface Interface {
|
||||
readonly create: (input?: Session.CreateInput) => Effect.Effect<Session.Info>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
RUN_COMMAND_PANEL_ROWS,
|
||||
RUN_SUBAGENT_PANEL_ROWS,
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
RunProvider,
|
||||
StreamCommit,
|
||||
} from "@/cli/cmd/run/types"
|
||||
import { RunQuestionBody } from "@/cli/cmd/run/footer.question"
|
||||
|
||||
function bindings(...keys: string[]) {
|
||||
return keys.map((key) => ({ key }))
|
||||
@@ -401,6 +403,53 @@ test("direct footer shows subagent indicator while prompt is running", async ()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct question body separates single-select checkmark from label", async () => {
|
||||
const request = {
|
||||
id: "question-1",
|
||||
sessionID: "session-1",
|
||||
questions: [
|
||||
{
|
||||
question: "Which categorical concept is often described as a universal way to combine two objects?",
|
||||
header: "Universal Product",
|
||||
options: [
|
||||
{ label: "Product", description: "A product comes with projections." },
|
||||
{ label: "Equalizer", description: "An equalizer selects morphisms where arrows agree." },
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies QuestionRequest
|
||||
const replies: unknown[] = []
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={100} height={12}>
|
||||
<RunQuestionBody
|
||||
request={request}
|
||||
theme={RUN_THEME_FALLBACK.footer}
|
||||
onReply={(input) => {
|
||||
replies.push(input)
|
||||
}}
|
||||
onReject={() => {}}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{
|
||||
width: 100,
|
||||
height: 12,
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(replies).toHaveLength(1)
|
||||
expect(app.captureCharFrame()).toContain("Product ✓")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct model panel renders current model selector", async () => {
|
||||
const [providers] = createSignal<RunProvider[] | undefined>([provider()])
|
||||
const [current] = createSignal<RunInput["model"]>({ providerID: "opencode", modelID: "gpt-5" })
|
||||
|
||||
@@ -432,15 +432,11 @@ test("inserts spacers for new visible groups", async () => {
|
||||
// before/after the highlight resolution in a way that drops rows on
|
||||
// that platform.
|
||||
//
|
||||
// The Linux pass path takes `useThread = false` (see
|
||||
// `@opentui/core/testing.js` line ~540) which serializes the FFI render
|
||||
// thread. macOS passes despite `useThread = true`, so the divergence is
|
||||
// likely either Bun's microtask scheduling on Windows or a Zig-side
|
||||
// threading interaction during the second `renderSurface()` pass in
|
||||
// `settleSurface`. A real fix probably belongs in opentui (either force
|
||||
// `useThread=false` for testing on Windows, or eagerly call
|
||||
// `textBuffer.setText` in `CodeRenderable.set content` when streaming
|
||||
// updates a non-empty body).
|
||||
// Linux CI can also drop the first paragraph of the replayed reasoning block,
|
||||
// so this test asserts the stable second paragraph instead of the first-line
|
||||
// `Thinking:` label. A real fix probably belongs in opentui (either force
|
||||
// deterministic rendering for tests, or eagerly call `textBuffer.setText` in
|
||||
// `CodeRenderable.set content` when streaming updates a non-empty body).
|
||||
//
|
||||
// Skipping on win32 unblocks unrelated PRs; the assertion is still
|
||||
// exercised on Linux and macOS in CI.
|
||||
@@ -471,8 +467,7 @@ test.skipIf(process.platform === "win32")(
|
||||
|
||||
const output = lines.join("\n")
|
||||
expect(output).toContain("› Hello you")
|
||||
expect(output).toContain("Thinking:")
|
||||
expect(output).toContain("Plan")
|
||||
expect(output).toContain("Say hello.")
|
||||
expect(output).toContain("Hello.")
|
||||
} finally {
|
||||
out.scrollback.destroy()
|
||||
|
||||
@@ -171,7 +171,7 @@ function globalSse(stream: GlobalEventStream) {
|
||||
function wrapGlobalStream(stream: EventStream): GlobalEventStream {
|
||||
return (async function* (): GlobalEventStream {
|
||||
for await (const event of stream) {
|
||||
yield globalEvent(event)
|
||||
yield globalEvent(event as GlobalEvent["payload"])
|
||||
}
|
||||
return StreamClosed
|
||||
})()
|
||||
@@ -339,11 +339,11 @@ function child(id: string): SessionChild {
|
||||
}
|
||||
}
|
||||
|
||||
function globalEvent(payload: GlobalEvent["payload"]): GlobalEvent {
|
||||
function globalEvent(payload: SdkEvent | GlobalEvent["payload"]): GlobalEvent {
|
||||
return {
|
||||
directory: "/tmp",
|
||||
project: "project-1",
|
||||
payload,
|
||||
payload: payload as GlobalEvent["payload"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
|
||||
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||
@@ -17,7 +17,10 @@ async function wait(fn: () => boolean, timeout = 2000) {
|
||||
}
|
||||
}
|
||||
|
||||
function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent {
|
||||
function event(
|
||||
payload: GlobalEvent["payload"],
|
||||
input: { directory: string; project?: string; workspace?: string },
|
||||
): GlobalEvent {
|
||||
return {
|
||||
directory: input.directory,
|
||||
project: input.project,
|
||||
@@ -26,7 +29,7 @@ function event(payload: Event, input: { directory: string; project?: string; wor
|
||||
}
|
||||
}
|
||||
|
||||
function vcs(branch: string): Event {
|
||||
function vcs(branch: string): GlobalEvent["payload"] {
|
||||
return {
|
||||
id: `evt_vcs_${branch}`,
|
||||
type: "vcs.branch.updated",
|
||||
@@ -36,7 +39,7 @@ function vcs(branch: string): Event {
|
||||
}
|
||||
}
|
||||
|
||||
function update(version: string): Event {
|
||||
function update(version: string): GlobalEvent["payload"] {
|
||||
return {
|
||||
id: `evt_update_${version}`,
|
||||
type: "installation.update-available",
|
||||
@@ -67,7 +70,7 @@ function createSource() {
|
||||
|
||||
async function mount() {
|
||||
const source = createSource()
|
||||
const seen: Event[] = []
|
||||
const seen: GlobalEvent["payload"][] = []
|
||||
const workspaces: Array<string | undefined> = []
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
@@ -102,7 +105,7 @@ async function mount() {
|
||||
}
|
||||
|
||||
function Probe(props: {
|
||||
seen: Event[]
|
||||
seen: GlobalEvent["payload"][]
|
||||
workspaces: Array<string | undefined>
|
||||
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
|
||||
}) {
|
||||
@@ -111,7 +114,7 @@ function Probe(props: {
|
||||
|
||||
onMount(() => {
|
||||
event.subscribe((evt, { workspace }) => {
|
||||
props.seen.push(evt)
|
||||
props.seen.push(evt as GlobalEvent["payload"])
|
||||
props.workspaces.push(workspace)
|
||||
})
|
||||
props.onReady({ project })
|
||||
|
||||
@@ -5,8 +5,7 @@ import Http from "node:http"
|
||||
import path from "node:path"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Effect, Layer, ManagedRuntime, Schema } from "effect"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -30,8 +29,8 @@ import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
|
||||
import * as Workspace from "../../src/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceLayer } from "@/project/instance-layer"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Auth } from "@/auth"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
@@ -122,14 +121,9 @@ afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(InstanceLayer.layer, Workspace.defaultLayer, SessionNs.defaultLayer),
|
||||
{ memoMap },
|
||||
)
|
||||
|
||||
async function withInstance<T>(fn: (ctx: InstanceContext) => T | Promise<T>) {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const ctx = await runtime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: tmp.path })))
|
||||
const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: tmp.path })))
|
||||
return await context.provide(ctx, () => fn(ctx))
|
||||
}
|
||||
|
||||
@@ -155,7 +149,7 @@ function currentInstance() {
|
||||
|
||||
const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => {
|
||||
const ctx = currentInstance()
|
||||
return runtime.runPromise(ctx ? effect.pipe(Effect.provideService(InstanceRef, ctx)) : effect)
|
||||
return AppRuntime.runPromise(ctx ? effect.pipe(Effect.provideService(InstanceRef, ctx)) : effect)
|
||||
}
|
||||
const createWorkspace = (input: Workspace.CreateInput) =>
|
||||
runWorkspace(Workspace.Service.use((workspace) => workspace.create(input)))
|
||||
@@ -938,12 +932,12 @@ describe("workspace CRUD", () => {
|
||||
const previous = workspaceInfo(projectID, previousType)
|
||||
insertWorkspace(previous)
|
||||
registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter)
|
||||
const session = await runtime.runPromise(
|
||||
const session = await AppRuntime.runPromise(
|
||||
SessionNs.Service.use((svc) => svc.create({})).pipe(Effect.provideService(InstanceRef, instance)),
|
||||
)
|
||||
attachSessionToWorkspace(session.id, previous.id)
|
||||
|
||||
const workspaceCtx = await runtime.runPromise(
|
||||
const workspaceCtx = await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) => store.load({ directory: workspaceTmp.path })),
|
||||
)
|
||||
const workspaceProjectID = await context.provide(workspaceCtx, async () => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { test, type TestOptions } from "bun:test"
|
||||
import { Cause, Duration, Effect, Exit, Layer } from "effect"
|
||||
import * as Scope from "effect/Scope"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import * as TestConsole from "effect/testing/TestConsole"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import type { Config } from "@/config/config"
|
||||
import { TestInstance, withTmpdirInstance } from "../fixture/fixture"
|
||||
|
||||
@@ -25,9 +24,7 @@ function instanceArgs(
|
||||
|
||||
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
||||
|
||||
type Runner = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) => Promise<A>
|
||||
|
||||
const isolatedRun: Runner = (value, layer) =>
|
||||
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
|
||||
if (Exit.isFailure(exit)) {
|
||||
@@ -38,25 +35,7 @@ const isolatedRun: Runner = (value, layer) =>
|
||||
return yield* exit
|
||||
}).pipe(Effect.runPromise)
|
||||
|
||||
// Builds the layer through the shared process-wide memoMap so cached services
|
||||
// (Bus, Session, …) match Server.Default's instances. Use for tests that
|
||||
// publish to an in-process HTTP server and need pub/sub identity with the
|
||||
// server's handlers.
|
||||
const sharedRun: Runner = (value, layer) =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make()
|
||||
const ctx = yield* Layer.buildWithMemoMap(layer, memoMap, scope)
|
||||
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(ctx), Effect.exit)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
if (Exit.isFailure(exit)) {
|
||||
for (const err of Cause.prettyErrors(exit.cause)) {
|
||||
yield* Effect.logError(err)
|
||||
}
|
||||
}
|
||||
return yield* exit
|
||||
}).pipe(Effect.runPromise)
|
||||
|
||||
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>, run: Runner = isolatedRun) => {
|
||||
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>) => {
|
||||
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||
test(name, () => run(value, testLayer), opts)
|
||||
|
||||
@@ -131,13 +110,6 @@ export const it = make(testEnv, liveEnv)
|
||||
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
|
||||
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
|
||||
|
||||
// Variant of `testEffect` that builds the test layer through the shared
|
||||
// process-wide memoMap so services like Bus/Session resolve to the same
|
||||
// instances Server.Default uses. Use when a test needs pub/sub identity with
|
||||
// an in-process HTTP server — most tests should stick with `testEffect`.
|
||||
export const testEffectShared = <R, E>(layer: Layer.Layer<R, E>) =>
|
||||
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), sharedRun)
|
||||
|
||||
export const awaitWithTimeout = <A, E, R>(
|
||||
self: Effect.Effect<A, E, R>,
|
||||
message: string,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Effect, Queue, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
|
||||
export const SseEvent = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
type: Schema.String,
|
||||
properties: Schema.Record(Schema.String, Schema.Any),
|
||||
})
|
||||
|
||||
export type SseEvent = Schema.Schema.Type<typeof SseEvent>
|
||||
|
||||
function decodeFrames(text: string): SseEvent[] {
|
||||
return text
|
||||
.split(/\n\n+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => Schema.decodeUnknownSync(SseEvent)(JSON.parse(part.replace(/^data: /, ""))))
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a scoped subscription to the instance `/event` SSE stream and returns
|
||||
* a Queue of decoded events. The underlying request and decoder fiber are
|
||||
* released when the test scope closes.
|
||||
*/
|
||||
export const openInstanceEventStream = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.get(EventPaths.event).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", directory),
|
||||
HttpClient.execute,
|
||||
)
|
||||
const queue = yield* Queue.unbounded<SseEvent>()
|
||||
yield* response.stream.pipe(
|
||||
Stream.decodeText({ encoding: "utf-8" }),
|
||||
Stream.flatMap((text) => Stream.fromIterable(decodeFrames(text))),
|
||||
Stream.runForEach((event) => Queue.offer(queue, event)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return queue
|
||||
})
|
||||
|
||||
export const readNextEvent = (queue: Queue.Queue<SseEvent>) =>
|
||||
Queue.take(queue).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "3 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out reading SSE event")),
|
||||
}),
|
||||
)
|
||||
|
||||
export const collectUntilEvent = (queue: Queue.Queue<SseEvent>, predicate: (event: SseEvent) => boolean) =>
|
||||
Effect.gen(function* () {
|
||||
const events: SseEvent[] = []
|
||||
while (true) {
|
||||
const event = yield* readNextEvent(queue)
|
||||
events.push(event)
|
||||
if (predicate(event)) return events
|
||||
}
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "4 seconds",
|
||||
orElse: () => Effect.fail(new Error("collectUntilEvent deadline exceeded")),
|
||||
}),
|
||||
)
|
||||
@@ -1,25 +1,31 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { afterEach, test, expect, describe } from "bun:test"
|
||||
import path from "path"
|
||||
import { unlink } from "fs/promises"
|
||||
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { disposeAllInstances, tmpdir, withTestInstance } from "../fixture/fixture"
|
||||
import type { InstanceContext } from "../../src/project/instance-context"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Env } from "../../src/env"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { disposeAllInstances } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer))
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
|
||||
const env = makeRuntime(Env.Service, Env.defaultLayer)
|
||||
const originalEnv = new Map<string, string | undefined>()
|
||||
|
||||
const set = (k: string, v: string) =>
|
||||
Effect.gen(function* () {
|
||||
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
|
||||
process.env[k] = v
|
||||
yield* Env.Service.use((svc) => svc.set(k, v))
|
||||
})
|
||||
function rememberEnv(k: string) {
|
||||
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
|
||||
}
|
||||
|
||||
const set = (ctx: InstanceContext, k: string, v: string) => {
|
||||
rememberEnv(k)
|
||||
process.env[k] = v
|
||||
return env.runSync((svc) => svc.set(k, v).pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [key, value] of originalEnv) {
|
||||
@@ -30,250 +36,427 @@ afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const list = Provider.Service.use((svc) => svc.list())
|
||||
|
||||
const withAuthJson = (contents: string) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
let original: string | undefined
|
||||
try {
|
||||
original = await Filesystem.readText(authPath)
|
||||
} catch {
|
||||
original = undefined
|
||||
}
|
||||
await Filesystem.write(authPath, contents)
|
||||
return { authPath, original }
|
||||
}),
|
||||
({ authPath, original }) =>
|
||||
Effect.promise(async () => {
|
||||
if (original !== undefined) {
|
||||
await Filesystem.write(authPath, original)
|
||||
return
|
||||
}
|
||||
await unlink(authPath).catch(() => undefined)
|
||||
}),
|
||||
async function list(ctx: InstanceContext) {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.list()
|
||||
}).pipe(Effect.provideService(InstanceRef, ctx)),
|
||||
)
|
||||
}
|
||||
|
||||
it.instance(
|
||||
"Bedrock: config region takes precedence over AWS_REGION env var",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_REGION", "us-east-1")
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "eu-west-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
set(ctx, "AWS_REGION", "us-east-1")
|
||||
set(ctx, "AWS_PROFILE", "default")
|
||||
const providers = await list(ctx)
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
}),
|
||||
{ config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"Bedrock: falls back to AWS_REGION env var when no config region",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_REGION", "eu-west-1")
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
test("Bedrock: falls back to AWS_REGION env var when no config region", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
set(ctx, "AWS_REGION", "eu-west-1")
|
||||
set(ctx, "AWS_PROFILE", "default")
|
||||
const providers = await list(ctx)
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"Bedrock: loads when bearer token from auth.json is present",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withAuthJson(JSON.stringify({ "amazon-bedrock": { type: "api", key: "test-bearer-token" } }))
|
||||
yield* set("AWS_PROFILE", "")
|
||||
yield* set("AWS_ACCESS_KEY_ID", "")
|
||||
yield* set("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
}),
|
||||
{ config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
|
||||
)
|
||||
test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "eu-west-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"Bedrock: config profile takes precedence over AWS_PROFILE env var",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
yield* set("AWS_ACCESS_KEY_ID", "test-key-id")
|
||||
const providers = yield* list
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
// Save original auth.json if it exists
|
||||
let originalAuth: string | undefined
|
||||
try {
|
||||
originalAuth = await Filesystem.readText(authPath)
|
||||
} catch {
|
||||
// File doesn't exist, that's fine
|
||||
}
|
||||
|
||||
try {
|
||||
// Write test auth.json
|
||||
await Filesystem.write(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
"amazon-bedrock": {
|
||||
type: "api",
|
||||
key: "test-bearer-token",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
set(ctx, "AWS_PROFILE", "")
|
||||
set(ctx, "AWS_ACCESS_KEY_ID", "")
|
||||
set(ctx, "AWS_BEARER_TOKEN_BEDROCK", "")
|
||||
const providers = await list(ctx)
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
// Restore original or delete
|
||||
if (originalAuth !== undefined) {
|
||||
await Filesystem.write(authPath, originalAuth)
|
||||
} else {
|
||||
try {
|
||||
await unlink(authPath)
|
||||
} catch {
|
||||
// Ignore errors if file doesn't exist
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
profile: "my-custom-profile",
|
||||
region: "us-east-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
set(ctx, "AWS_PROFILE", "default")
|
||||
set(ctx, "AWS_ACCESS_KEY_ID", "test-key-id")
|
||||
const providers = await list(ctx)
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: { "amazon-bedrock": { options: { profile: "my-custom-profile", region: "us-east-1" } } },
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"Bedrock: includes custom endpoint in options when specified",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
test("Bedrock: includes custom endpoint in options when specified", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
set(ctx, "AWS_PROFILE", "default")
|
||||
const providers = await list(ctx)
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe(
|
||||
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
|
||||
)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
|
||||
yield* set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
|
||||
yield* set("AWS_PROFILE", "")
|
||||
yield* set("AWS_ACCESS_KEY_ID", "")
|
||||
const providers = yield* list
|
||||
test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "us-east-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
set(ctx, "AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
|
||||
set(ctx, "AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
|
||||
set(ctx, "AWS_PROFILE", "")
|
||||
set(ctx, "AWS_ACCESS_KEY_ID", "")
|
||||
const providers = await list(ctx)
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
|
||||
}),
|
||||
{ config: { provider: { "amazon-bedrock": { options: { region: "us-east-1" } } } } },
|
||||
)
|
||||
|
||||
// Cross-region inference profile prefix handling.
|
||||
// Models from models.dev may come with prefixes already (e.g. us., eu., global.).
|
||||
// These should NOT be double-prefixed when passed to the SDK.
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model with us. prefix should not be double-prefixed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-1" },
|
||||
models: { "us.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (US)" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model with global. prefix should not be prefixed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
// Tests for cross-region inference profile prefix handling
|
||||
// Models from models.dev may come with prefixes already (e.g., us., eu., global.)
|
||||
// These should NOT be double-prefixed when passed to the SDK
|
||||
|
||||
test("Bedrock: model with us. prefix should not be double-prefixed", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "us-east-1",
|
||||
},
|
||||
models: {
|
||||
"us.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
name: "Claude Opus 4.5 (US)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
set(ctx, "AWS_PROFILE", "default")
|
||||
const providers = await list(ctx)
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
// The model should exist with the us. prefix
|
||||
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: model with global. prefix should not be prefixed", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "us-east-1",
|
||||
},
|
||||
models: {
|
||||
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
name: "Claude Opus 4.5 (Global)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
set(ctx, "AWS_PROFILE", "default")
|
||||
const providers = await list(ctx)
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-1" },
|
||||
models: { "global.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (Global)" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model with eu. prefix should not be double-prefixed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
test("Bedrock: model with eu. prefix should not be double-prefixed", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "eu-west-1",
|
||||
},
|
||||
models: {
|
||||
"eu.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
name: "Claude Opus 4.5 (EU)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
set(ctx, "AWS_PROFILE", "default")
|
||||
const providers = await list(ctx)
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "eu-west-1" },
|
||||
models: { "eu.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (EU)" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model without prefix in US region should get us. prefix added",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
test("Bedrock: model without prefix in US region should get us. prefix added", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "us-east-1",
|
||||
},
|
||||
models: {
|
||||
"anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
name: "Claude Opus 4.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
set(ctx, "AWS_PROFILE", "default")
|
||||
const providers = await list(ctx)
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
// Non-prefixed model should still be registered
|
||||
expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-1" },
|
||||
models: { "anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Direct unit tests for cross-region inference profile prefix handling
|
||||
// These test the prefix detection logic used in getModel
|
||||
|
||||
// Direct unit tests for cross-region inference profile prefix detection.
|
||||
describe("Bedrock cross-region prefix detection", () => {
|
||||
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
|
||||
|
||||
test("should detect global. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "global.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
|
||||
const modelID = "global.anthropic.claude-opus-4-5-20251101-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect us. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "us.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
|
||||
const modelID = "us.anthropic.claude-opus-4-5-20251101-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect eu. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "eu.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
|
||||
const modelID = "eu.anthropic.claude-opus-4-5-20251101-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect jp. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "jp.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true)
|
||||
const modelID = "jp.anthropic.claude-sonnet-4-20250514-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect apac. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "apac.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true)
|
||||
const modelID = "apac.anthropic.claude-sonnet-4-20250514-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect au. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "au.anthropic.claude-sonnet-4-5-20250929-v1:0".startsWith(p))).toBe(true)
|
||||
const modelID = "au.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for non-prefixed model", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(false)
|
||||
const modelID = "anthropic.claude-opus-4-5-20251101-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(false)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for amazon nova models", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "amazon.nova-pro-v1:0".startsWith(p))).toBe(false)
|
||||
const modelID = "amazon.nova-pro-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(false)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for cohere models", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "cohere.command-r-plus-v1:0".startsWith(p))).toBe(false)
|
||||
const modelID = "cohere.command-r-plus-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,8 +12,8 @@ import { Provider } from "@/provider/provider"
|
||||
import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -66,10 +66,8 @@ const providerLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
)
|
||||
|
||||
const providerRuntime = ManagedRuntime.make(Layer.mergeAll(Provider.defaultLayer, Plugin.defaultLayer), { memoMap })
|
||||
|
||||
async function run<A, E>(ctx: InstanceContext, fn: (provider: Provider.Interface) => Effect.Effect<A, E, never>) {
|
||||
return providerRuntime.runPromise(
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* fn(provider)
|
||||
@@ -2511,7 +2509,7 @@ test("plugin config providers persist after instance dispose", async () => {
|
||||
const first = await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) =>
|
||||
providerRuntime.runPromise(
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
@@ -1,50 +1,53 @@
|
||||
// Diagnostic suite for /event SSE delivery.
|
||||
//
|
||||
// Each test isolates ONE variable in the publisher chain while keeping the
|
||||
// subscriber path constant (HttpClient against the in-process HttpApiApp
|
||||
// routes, reading the SSE body via openInstanceEventStream). The pass/fail
|
||||
// pattern across tests tells us where the bug lives:
|
||||
// subscriber path constant (raw `app().request` reading the SSE body — no SDK
|
||||
// consumer involvement). The pass/fail pattern across tests tells us where the
|
||||
// bug lives:
|
||||
//
|
||||
// D1 (baseline): publish via Bus.Service.use — mirror of the existing
|
||||
// httpapi-event.test.ts test 3. Confirms /event SSE delivery works for
|
||||
// a SOME publish path.
|
||||
// D1 (baseline): publish via Bus.Service.use via AppRuntime — mirror of the
|
||||
// existing httpapi-event.test.ts test 3. Confirms /event SSE delivery
|
||||
// works for a SOME publish path.
|
||||
//
|
||||
// D2: publish N times in quick succession via Bus.Service.use. If the bus
|
||||
// subscription is acquired correctly there should be no message loss.
|
||||
//
|
||||
// D3: publish via SyncEvent.use.run — exercises the same path the HTTP
|
||||
// handlers use (Session.updatePart → sync.run → bus.publish) without the
|
||||
// HTTP roundtrip. Tells us whether the sync path itself can deliver
|
||||
// in-process.
|
||||
// D3: publish via SyncEvent.use.run via AppRuntime — exercises the same path
|
||||
// the HTTP handlers use (Session.updatePart → sync.run → bus.publish)
|
||||
// without the HTTP roundtrip. Tells us whether the sync path itself can
|
||||
// deliver in-process.
|
||||
//
|
||||
// D4: publish via SyncEvent.use.run; subscriber is an in-process Bus
|
||||
// callback. Confirms pub/sub identity end-to-end without /event SSE.
|
||||
// D4: publish via SyncEvent.use.run from a fresh `Effect.provide` scope
|
||||
// (mimicking what happens if a handler's layer was scoped per-request).
|
||||
//
|
||||
// D5: in-process Bus.Service callback subscriber AND raw /event SSE subscriber
|
||||
// receive the same publish. If both receive: no bug. If only the
|
||||
// callback receives: the /event handler has an acquisition race.
|
||||
//
|
||||
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
|
||||
// subscription is established. Order-of-setup variable.
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Config, Deferred, Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { Deferred, Effect, Schema } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { type AppServices, AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Event as ServerEvent } from "../../src/server/event"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { SyncEvent } from "../../src/sync"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffectShared } from "../lib/effect"
|
||||
import { collectUntilEvent, openInstanceEventStream, readNextEvent, type SseEvent } from "../lib/sse"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const EventData = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
type: Schema.String,
|
||||
properties: Schema.Record(Schema.String, Schema.Any),
|
||||
})
|
||||
|
||||
type SseEvent = Schema.Schema.Type<typeof EventData>
|
||||
type BusEvent = { type: string; properties: unknown }
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -52,37 +55,80 @@ afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
HttpApiApp.routes,
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
)
|
||||
const inApp = <A, E>(eff: Effect.Effect<A, E, AppServices>) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die("InstanceRef not provided in test scope")
|
||||
return yield* Effect.promise(() => AppRuntime.runPromise(eff.pipe(Effect.provideService(InstanceRef, ctx))))
|
||||
})
|
||||
|
||||
const it = testEffectShared(
|
||||
Layer.mergeAll(
|
||||
Bus.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
servedRoutes.pipe(
|
||||
Layer.provide(Socket.layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const publishConnected = Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {}))
|
||||
const publishConnected = inApp(Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})))
|
||||
|
||||
const publishPartUpdated = (partID: ReturnType<typeof PartID.ascending>) => {
|
||||
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
|
||||
return SyncEvent.use.run(MessageV2.Event.PartUpdated, {
|
||||
sessionID,
|
||||
part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" },
|
||||
time: Date.now(),
|
||||
})
|
||||
return inApp(
|
||||
SyncEvent.use.run(MessageV2.Event.PartUpdated, {
|
||||
sessionID,
|
||||
part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" },
|
||||
time: Date.now(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const subscribeAllCallback = (handler: (event: BusEvent) => void) =>
|
||||
Effect.acquireRelease(Bus.Service.use((svc) => svc.subscribeAllCallback(handler)), (dispose) =>
|
||||
Effect.sync(dispose),
|
||||
Effect.acquireRelease(inApp(Bus.Service.use((svc) => svc.subscribeAllCallback(handler))), (dispose) =>
|
||||
Effect.sync(() => dispose()),
|
||||
)
|
||||
|
||||
const openEventStream = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }),
|
||||
)
|
||||
if (!response.body) return yield* Effect.die("missing SSE response body")
|
||||
const reader = response.body.getReader()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined)))
|
||||
return reader
|
||||
})
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
function decodeFrame(value: Uint8Array): SseEvent[] {
|
||||
return decoder
|
||||
.decode(value)
|
||||
.split(/\n\n+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => Schema.decodeUnknownSync(EventData)(JSON.parse(part.replace(/^data: /, ""))))
|
||||
}
|
||||
|
||||
const readNextEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
|
||||
Effect.promise(() => reader.read()).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "3 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out reading SSE chunk")),
|
||||
}),
|
||||
Effect.flatMap((result) => {
|
||||
if (result.done || !result.value) return Effect.fail(new Error("event stream closed"))
|
||||
const frames = decodeFrame(result.value)
|
||||
if (frames.length === 0) return Effect.fail(new Error("empty SSE frame"))
|
||||
return Effect.succeed(frames[0])
|
||||
}),
|
||||
)
|
||||
|
||||
const collectUntilEvent = (reader: ReadableStreamDefaultReader<Uint8Array>, predicate: (event: SseEvent) => boolean) =>
|
||||
Effect.gen(function* () {
|
||||
const events: SseEvent[] = []
|
||||
while (true) {
|
||||
const event = yield* readNextEvent(reader)
|
||||
events.push(event)
|
||||
if (predicate(event)) return events
|
||||
}
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "4 seconds",
|
||||
orElse: () => Effect.fail(new Error("collectUntil deadline exceeded")),
|
||||
}),
|
||||
)
|
||||
|
||||
const isPartUpdated = (event: { type: string }) => event.type === MessageV2.Event.PartUpdated.type
|
||||
@@ -96,7 +142,7 @@ describe("/event SSE delivery diagnostics", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const reader = yield* openInstanceEventStream(directory)
|
||||
const reader = yield* openEventStream(directory)
|
||||
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
yield* publishConnected
|
||||
@@ -111,7 +157,7 @@ describe("/event SSE delivery diagnostics", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const reader = yield* openInstanceEventStream(directory)
|
||||
const reader = yield* openEventStream(directory)
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const N = 5
|
||||
@@ -126,13 +172,13 @@ describe("/event SSE delivery diagnostics", () => {
|
||||
|
||||
// The critical test. If D1 passes but this fails, the bus-identity fix is
|
||||
// incomplete OR the sync.run publish path doesn't reach the same bus
|
||||
// /event subscribes to, even when both share the memoMap.
|
||||
// /event subscribes to, even within the same AppRuntime.
|
||||
it.instance(
|
||||
"D3: delivers a SyncEvent published via SyncEvent.use.run after server.connected",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const reader = yield* openInstanceEventStream(directory)
|
||||
const reader = yield* openEventStream(directory)
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const partID = PartID.ascending()
|
||||
@@ -140,8 +186,7 @@ describe("/event SSE delivery diagnostics", () => {
|
||||
|
||||
const collected = yield* collectUntilEvent(reader, isPartUpdated)
|
||||
const updated = collected.find(isPartUpdated)
|
||||
expect(updated).toBeDefined()
|
||||
expect((updated as SseEvent).properties.part.id).toBe(partID)
|
||||
expect(updated?.properties.part.id).toBe(partID)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
@@ -171,7 +216,7 @@ describe("/event SSE delivery diagnostics", () => {
|
||||
}),
|
||||
)
|
||||
expect(event.type).toBe(MessageV2.Event.PartUpdated.type)
|
||||
expect((event.properties as { part: { id: string } }).part.id).toBe(partID)
|
||||
expect(event.properties).toMatchObject({ part: { id: partID } })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
@@ -188,7 +233,7 @@ describe("/event SSE delivery diagnostics", () => {
|
||||
yield* subscribeAllCallback((event) => {
|
||||
if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event))
|
||||
})
|
||||
const reader = yield* openInstanceEventStream(directory)
|
||||
const reader = yield* openEventStream(directory)
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const partID = PartID.ascending()
|
||||
@@ -210,6 +255,29 @@ describe("/event SSE delivery diagnostics", () => {
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
// D7: like D5 but the "second subscriber" is a NO-OP AppRuntime.runPromise
|
||||
// call (no PubSub.subscribe). If D7 passes, the specific subscribeAllCallback
|
||||
// is what breaks SSE — not arbitrary AppRuntime usage. If D7 fails, anything
|
||||
// running through AppRuntime concurrently with /event SSE breaks delivery.
|
||||
it.instance(
|
||||
"D7: SSE receives sync.run publish even with concurrent no-op AppRuntime activity",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
yield* inApp(Effect.void)
|
||||
|
||||
const reader = yield* openEventStream(directory)
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const partID = PartID.ascending()
|
||||
yield* publishPartUpdated(partID)
|
||||
|
||||
const collected = yield* collectUntilEvent(reader, isPartUpdated)
|
||||
expect(collected.find(isPartUpdated)).toBeDefined()
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
|
||||
// subscription is established. If D5 fails and D6 passes, the order of
|
||||
// subscriber setup is the determining factor.
|
||||
@@ -218,7 +286,7 @@ describe("/event SSE delivery diagnostics", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const reader = yield* openInstanceEventStream(directory)
|
||||
const reader = yield* openEventStream(directory)
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const callbackReceived = yield* Deferred.make<BusEvent>()
|
||||
|
||||
@@ -1,95 +1,121 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Config, Effect, Layer, Queue } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Event as ServerEvent } from "../../src/server/event"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Event as ServerEvent } from "../../src/server/event"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffectShared } from "../lib/effect"
|
||||
import { openInstanceEventStream, readNextEvent } from "../lib/sse"
|
||||
import { disposeAllInstances, reloadTestInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
const EventData = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
type: Schema.String,
|
||||
properties: Schema.Record(Schema.String, Schema.Any),
|
||||
})
|
||||
|
||||
async function readChunk(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error("timed out waiting for event")), 5_000)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
async function readFirstEvent(response: Response) {
|
||||
if (!response.body) throw new Error("missing response body")
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
return await readEvent(reader)
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
const result = await readChunk(reader)
|
||||
if (result.done || !result.value) throw new Error("event stream closed")
|
||||
return Schema.decodeUnknownSync(EventData)(JSON.parse(new TextDecoder().decode(result.value).replace(/^data: /, "")))
|
||||
}
|
||||
|
||||
async function readStatusWithin(reader: ReadableStreamDefaultReader<Uint8Array>, delay: number) {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
reader.read().then((result) => (result.done ? "closed" : "event")),
|
||||
new Promise<"open">((resolve) => {
|
||||
timeout = setTimeout(() => resolve("open"), delay)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
HttpApiApp.routes,
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
)
|
||||
|
||||
const it = testEffectShared(
|
||||
Layer.mergeAll(
|
||||
Bus.defaultLayer,
|
||||
servedRoutes.pipe(
|
||||
Layer.provide(Socket.layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("event HttpApi", () => {
|
||||
it.instance(
|
||||
"serves event stream with correct headers and initial server.connected",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const response = yield* HttpClientRequest.get(EventPaths.event).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", directory),
|
||||
HttpClient.execute,
|
||||
)
|
||||
test("serves event stream", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers["content-type"]).toContain("text/event-stream")
|
||||
expect(response.headers["cache-control"]).toBe("no-cache, no-transform")
|
||||
expect(response.headers["x-accel-buffering"]).toBe("no")
|
||||
expect(response.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream")
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache, no-transform")
|
||||
expect(response.headers.get("x-accel-buffering")).toBe("no")
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
|
||||
expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
})
|
||||
|
||||
// Read one event to also verify the stream is wired up; the response is
|
||||
// scope-bound so the connection closes when the test ends.
|
||||
const events = yield* openInstanceEventStream(directory)
|
||||
expect(yield* readNextEvent(events)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
test("keeps the event stream open after the initial event", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
|
||||
if (!response.body) throw new Error("missing response body")
|
||||
|
||||
it.instance(
|
||||
"keeps the event stream open after the initial event",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const events = yield* openInstanceEventStream(directory)
|
||||
expect(yield* readNextEvent(events)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
expect(await readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
expect(await readStatusWithin(reader, 250)).toBe("open")
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
}
|
||||
})
|
||||
|
||||
// If no second event arrives within 250ms, the stream is still open.
|
||||
const status = yield* Queue.take(events).pipe(
|
||||
Effect.map(() => "event" as const),
|
||||
Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed("open" as const) }),
|
||||
)
|
||||
expect(status).toBe("open")
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
test("delivers instance bus events after the initial event", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } })
|
||||
if (!response.body) throw new Error("missing response body")
|
||||
|
||||
it.instance(
|
||||
"delivers instance bus events after the initial event",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const events = yield* openInstanceEventStream(directory)
|
||||
expect(yield* readNextEvent(events)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
expect(await readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
|
||||
yield* Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {}))
|
||||
expect(yield* readNextEvent(events)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
const next = readEvent(reader)
|
||||
const ctx = await reloadTestInstance({ directory: tmp.path })
|
||||
await AppRuntime.runPromise(
|
||||
Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})).pipe(Effect.provideService(InstanceRef, ctx)),
|
||||
)
|
||||
|
||||
expect(await next).toMatchObject({ type: "server.connected", properties: {} })
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { tool, type ModelMessage } from "ai"
|
||||
import { Cause, Effect, Exit, Layer, ManagedRuntime, Stream } from "effect"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import { Cause, Effect, Exit, Layer, Stream } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import z from "zod"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
@@ -22,6 +21,7 @@ import { tmpdir, withTestInstance } from "../fixture/fixture"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Permission } from "@/permission"
|
||||
import { LLMAISDK } from "@/session/llm/ai-sdk"
|
||||
@@ -50,14 +50,12 @@ const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: stri
|
||||
}
|
||||
}
|
||||
|
||||
const providerRuntime = ManagedRuntime.make(Provider.defaultLayer, { memoMap })
|
||||
|
||||
async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
|
||||
const effect = Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.getModel(providerID, modelID)
|
||||
})
|
||||
return providerRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
return AppRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
}
|
||||
|
||||
const llm = makeRuntime(LLM.Service, LLM.defaultLayer)
|
||||
|
||||
@@ -78,6 +78,10 @@ export type Event =
|
||||
| EventSessionNextCompactionDelta
|
||||
| EventSessionNextCompactionEnded
|
||||
| EventCatalogModelUpdated
|
||||
| EventModelsDevRefreshed
|
||||
| EventAccountAdded
|
||||
| EventAccountRemoved
|
||||
| EventAccountSwitched
|
||||
|
||||
export type OAuth = {
|
||||
type: "oauth"
|
||||
@@ -874,6 +878,10 @@ export type GlobalEvent = {
|
||||
| EventSessionNextCompactionDelta
|
||||
| EventSessionNextCompactionEnded
|
||||
| EventCatalogModelUpdated
|
||||
| EventModelsDevRefreshed
|
||||
| EventAccountAdded
|
||||
| EventAccountRemoved
|
||||
| EventAccountSwitched
|
||||
| SyncEventMessageUpdated
|
||||
| SyncEventMessageRemoved
|
||||
| SyncEventMessagePartUpdated
|
||||
@@ -3284,6 +3292,64 @@ export type EventCatalogModelUpdated = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventModelsDevRefreshed = {
|
||||
id: string
|
||||
type: "models-dev.refreshed"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountV2oAuthCredential = {
|
||||
type: "oauth"
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
}
|
||||
|
||||
export type AccountV2ApiKeyCredential = {
|
||||
type: "api"
|
||||
key: string
|
||||
metadata?: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountV2Credential = AccountV2oAuthCredential | AccountV2ApiKeyCredential
|
||||
|
||||
export type AccountV2Info = {
|
||||
id: string
|
||||
serviceID: string
|
||||
description: string
|
||||
credential: AccountV2Credential
|
||||
}
|
||||
|
||||
export type EventAccountAdded = {
|
||||
id: string
|
||||
type: "account.added"
|
||||
properties: {
|
||||
account: AccountV2Info
|
||||
}
|
||||
}
|
||||
|
||||
export type EventAccountRemoved = {
|
||||
id: string
|
||||
type: "account.removed"
|
||||
properties: {
|
||||
account: AccountV2Info
|
||||
}
|
||||
}
|
||||
|
||||
export type EventAccountSwitched = {
|
||||
id: string
|
||||
type: "account.switched"
|
||||
properties: {
|
||||
serviceID: string
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
parentID?: string
|
||||
@@ -3529,7 +3595,7 @@ export type ProviderV2Info = {
|
||||
name: string
|
||||
}
|
||||
| {
|
||||
via: "auth"
|
||||
via: "account"
|
||||
service: string
|
||||
}
|
||||
| {
|
||||
|
||||
+191
-1
@@ -10567,6 +10567,18 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextCompactionEnded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventModels-devRefreshed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventAccountAdded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventAccountRemoved"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventAccountSwitched"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -12979,6 +12991,18 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextCompactionEnded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventModels-devRefreshed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventAccountAdded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventAccountRemoved"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventAccountSwitched"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SyncEventMessageUpdated"
|
||||
},
|
||||
@@ -20308,6 +20332,172 @@
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventModels-devRefreshed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["models-dev.refreshed"]
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AccountV2OAuthCredential": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["oauth"]
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": ["type", "refresh", "access", "expires"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AccountV2ApiKeyCredential": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["api"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AccountV2Credential": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/AccountV2OAuthCredential"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AccountV2ApiKeyCredential"
|
||||
}
|
||||
]
|
||||
},
|
||||
"AccountV2Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"serviceID": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"credential": {
|
||||
"$ref": "#/components/schemas/AccountV2Credential"
|
||||
}
|
||||
},
|
||||
"required": ["id", "serviceID", "description", "credential"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventAccountAdded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["account.added"]
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
"$ref": "#/components/schemas/AccountV2Info"
|
||||
}
|
||||
},
|
||||
"required": ["account"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventAccountRemoved": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["account.removed"]
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account": {
|
||||
"$ref": "#/components/schemas/AccountV2Info"
|
||||
}
|
||||
},
|
||||
"required": ["account"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventAccountSwitched": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["account.switched"]
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"serviceID": {
|
||||
"type": "string"
|
||||
},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"to": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["serviceID"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -21021,7 +21211,7 @@
|
||||
"properties": {
|
||||
"via": {
|
||||
"type": "string",
|
||||
"enum": ["auth"]
|
||||
"enum": ["account"]
|
||||
},
|
||||
"service": {
|
||||
"type": "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.
|
||||
|
||||
@@ -256,6 +256,46 @@ const available = provider.enabled && model.status !== "deprecated"
|
||||
## Plugin Interface
|
||||
|
||||
```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<
|
||||
{
|
||||
readonly order: number
|
||||
@@ -280,7 +320,7 @@ export interface Interface {
|
||||
export const Order = {
|
||||
modelsDev: 0,
|
||||
env: 10,
|
||||
auth: 20,
|
||||
account: 20,
|
||||
provider: 30,
|
||||
config: 40,
|
||||
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 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 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 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
|
||||
|
||||
Reference in New Issue
Block a user