feat(core): add connector authentication (#31837)

This commit is contained in:
Dax 2026-06-11 02:31:17 -04:00 committed by GitHub
parent bf05e8a122
commit dac0dd5309
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 5433 additions and 862 deletions

View file

@ -0,0 +1,12 @@
CREATE TABLE `credential` (
`id` text PRIMARY KEY,
`connector_id` text NOT NULL,
`method_id` text NOT NULL,
`label` text NOT NULL,
`value` text NOT NULL,
`active` integer DEFAULT false NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `credential_connector_active_idx` ON `credential` (`connector_id`) WHERE "credential"."active" = 1;

File diff suppressed because it is too large Load diff

View file

@ -1,340 +0,0 @@
export * as Auth from "./auth"
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 { FSUtil } from "./fs-util"
import { EventV2 } from "./event"
export const ID = Schema.String.pipe(
Schema.brand("Auth.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 const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
export type OrgID = typeof OrgID.Type
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
export type AccessToken = typeof AccessToken.Type
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
export type RefreshToken = typeof RefreshToken.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("Auth.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("Auth.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: "Auth.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Info extends Schema.Class<Info>("Auth.Info")({
id: ID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("Auth.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 activeAll: () => Effect.Effect<Map<ServiceID, Info>, 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* FSUtil.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("Auth.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("Auth.get")(function* (id) {
return (yield* SynchronizedRef.get(state)).accounts[id]
}),
all: Effect.fn("Auth.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("Auth.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
activeAll: Effect.fn("Auth.activeAll")(function* () {
const data = yield* SynchronizedRef.get(state)
const result = new Map<ServiceID, Info>()
for (const account of Object.values(data.accounts)) {
if (!result.has(account.serviceID)) result.set(account.serviceID, account)
}
for (const [serviceID, id] of Object.entries(data.active)) {
const account = data.accounts[id]
if (account) result.set(ServiceID.make(serviceID), account)
}
return result
}),
forService: Effect.fn("Auth.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("Auth.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("Auth.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("Auth.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(FSUtil.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)

View file

@ -10,6 +10,8 @@ import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Credential } from "./credential"
import { ConnectorSchema } from "./connector/schema"
export type ProviderRecord = {
provider: ProviderV2.Info
@ -94,10 +96,26 @@ export const layer = Layer.effect(
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const credentials = yield* Credential.Service
const scope = yield* Scope.Scope
const resolve = (model: ModelV2.Info) => {
const provider = state.get().providers.get(model.providerID)!.provider
const project = (provider: ProviderV2.Info, active: Map<ConnectorSchema.ID, Credential.Info>) => {
const credential = active.get(ConnectorSchema.ID.make(provider.id))
if (!credential) return provider
const body = { ...provider.request.body }
if (credential.value.type === "key") {
body.apiKey = credential.value.key
Object.assign(body, credential.value.metadata ?? {})
}
if (credential.value.type === "oauth") body.apiKey = credential.value.access
return new ProviderV2.Info({
...provider,
enabled: { via: "credential", credentialID: credential.id },
request: { ...provider.request, body },
})
}
const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => {
const api =
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
? { ...provider.api, id: model.api.id }
@ -193,8 +211,7 @@ export const layer = Layer.effect(
}
}),
})
const available = (model: ModelV2.Info) =>
state.get().providers.get(model.providerID)?.provider.enabled !== false && model.enabled
const active = () => credentials.activeAll().pipe(Effect.orDie)
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
@ -214,17 +231,16 @@ export const layer = Layer.effect(
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID)
return record.provider
return project(record.provider, yield* active())
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
const credentials = yield* active()
return Array.fromIterable(state.get().providers.values()).map((record) => project(record.provider, credentials))
}),
available: Effect.fn("CatalogV2.provider.available")(function* () {
return Array.fromIterable(state.get().providers.values())
.map((record) => record.provider)
.filter((provider) => provider.enabled)
return (yield* result.provider.all()).filter((provider) => provider.enabled)
}),
},
@ -233,29 +249,35 @@ export const layer = Layer.effect(
const record = yield* getRecord(providerID)
const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return resolve(model)
return resolve(model, project(record.provider, yield* active()))
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
const credentials = yield* active()
return pipe(
Array.fromIterable(state.get().providers.values()),
Array.flatMap((record) => Array.fromIterable(record.models.values())),
Array.map(resolve),
Array.flatMap((record) => {
const provider = project(record.provider, credentials)
return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider))
}),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
}),
available: Effect.fn("CatalogV2.model.available")(function* () {
return (yield* result.model.all()).filter(available)
const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider]))
return (yield* result.model.all()).filter(
(model) => providers.get(model.providerID)?.enabled !== false && model.enabled,
)
}),
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = state.get().providers.get(defaultModel.providerID)?.provider
if (provider?.enabled !== false) {
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
if (Option.isSome(provider) && provider.value.enabled !== false) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && available(model.value)) return model
if (Option.isSome(model) && model.value.enabled) return model
}
}
@ -269,10 +291,11 @@ export const layer = Layer.effect(
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>()
const provider = project(record.provider, yield* active())
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider))
}
const candidates = pipe(
@ -300,7 +323,7 @@ export const layer = Layer.effect(
return pipe(
items,
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
Array.map((item) => resolve(item.model)),
Array.map((item) => resolve(item.model, provider)),
Array.head,
)
}

View file

@ -0,0 +1,496 @@
export * as Connector from "./connector"
import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { Credential } from "./credential"
import { ConnectorSchema } from "./connector/schema"
import { withStatics } from "./schema"
import { State } from "./state"
import { Identifier } from "./util/identifier"
import { KeyedMutex } from "./effect/keyed-mutex"
import { EventV2 } from "./event"
export const ID = ConnectorSchema.ID
export type ID = ConnectorSchema.ID
export const MethodID = ConnectorSchema.MethodID
export type MethodID = ConnectorSchema.MethodID
export const AttemptID = Schema.String.pipe(
Schema.brand("Connector.AttemptID"),
withStatics((schema) => ({ create: () => schema.make("con_" + Identifier.ascending()) })),
)
export type AttemptID = typeof AttemptID.Type
export const When = Schema.Struct({
key: Schema.String,
op: Schema.Literals(["eq", "neq"]),
value: Schema.String,
}).annotate({ identifier: "Connector.When" })
export type When = typeof When.Type
export class TextPrompt extends Schema.Class<TextPrompt>("Connector.TextPrompt")({
type: Schema.Literal("text"),
key: Schema.String,
message: Schema.String,
placeholder: Schema.optional(Schema.String),
when: Schema.optional(When),
}) {}
export class SelectPrompt extends Schema.Class<SelectPrompt>("Connector.SelectPrompt")({
type: Schema.Literal("select"),
key: Schema.String,
message: Schema.String,
options: Schema.Array(
Schema.Struct({
label: Schema.String,
value: Schema.String,
hint: Schema.optional(Schema.String),
}),
),
when: Schema.optional(When),
}) {}
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
export type Prompt = typeof Prompt.Type
export class OAuthMethod extends Schema.Class<OAuthMethod>("Connector.OAuthMethod")({
id: MethodID,
type: Schema.Literal("oauth"),
label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)),
}) {}
export class KeyMethod extends Schema.Class<KeyMethod>("Connector.KeyMethod")({
id: MethodID,
type: Schema.Literal("key"),
label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)),
}) {}
export const Method = Schema.Union([OAuthMethod, KeyMethod]).pipe(Schema.toTaggedUnion("type"))
export type Method = typeof Method.Type
export class Info extends Schema.Class<Info>("Connector.Info")({
id: ID,
name: Schema.String,
methods: Schema.Array(Method),
}) {}
export type Inputs = Readonly<{ [key: string]: string }>
export type OAuthAuthorization = {
readonly url: string
readonly instructions: string
} & (
| {
readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Value, unknown>
}
| {
readonly mode: "code"
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
}
)
export interface OAuthImplementation {
readonly connectorID: ID
readonly method: OAuthMethod
readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
}
export interface KeyImplementation {
readonly connectorID: ID
readonly method: KeyMethod
readonly authorize: (key: string, inputs: Inputs) => Effect.Effect<Credential.Key, unknown>
}
export type Implementation = OAuthImplementation | KeyImplementation
function isKeyImplementation(implementation: Implementation): implementation is KeyImplementation {
return implementation.method.type === "key"
}
function isOAuthImplementation(implementation: Implementation): implementation is OAuthImplementation {
return implementation.method.type === "oauth"
}
export class Attempt extends Schema.Class<Attempt>("Connector.Attempt")({
attemptID: AttemptID,
url: Schema.String,
instructions: Schema.String,
mode: Schema.Literals(["auto", "code"]),
time: Schema.Struct({
created: Schema.Number,
expires: Schema.Number,
}),
}) {}
const Time = Schema.Struct({
created: Schema.Number,
expires: Schema.Number,
})
export const AttemptStatus = Schema.Union([
Schema.Struct({ status: Schema.Literal("pending"), time: Time }),
Schema.Struct({ status: Schema.Literal("complete"), time: Time }),
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: Time }),
Schema.Struct({ status: Schema.Literal("expired"), time: Time }),
]).pipe(Schema.toTaggedUnion("status"))
export type AttemptStatus = typeof AttemptStatus.Type
export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError>()("Connector.CodeRequired", {
attemptID: AttemptID,
}) {}
export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationError>()("Connector.Authorization", {
cause: Schema.Defect,
}) {}
export type Error = CodeRequiredError | AuthorizationError
export const Event = {
Updated: EventV2.define({
type: "connector.updated",
schema: {},
}),
}
type Entry = {
connector: Info
implementations: Map<MethodID, Implementation>
}
type Data = {
connectors: Map<ID, Entry>
}
export type Editor = {
list: () => readonly Info[]
get: (id: ID) => Info | undefined
update: (id: ID, update: (connector: Draft<Omit<Info, "methods">>) => void) => void
remove: (id: ID) => void
method: {
update: (implementation: Implementation) => void
remove: (connectorID: ID, methodID: MethodID) => void
}
}
export interface Interface {
/** Registers a scoped transform over the connector registry. */
readonly transform: State.Interface<Data, Editor>["transform"]
/** Registers and immediately applies a scoped connector registry update. */
readonly update: State.Interface<Data, Editor>["update"]
/** Returns one connector with its serializable login methods. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Returns all connectors with their serializable login methods. */
readonly list: () => Effect.Effect<Info[]>
/** Refreshes an OAuth credential with its originating method. */
readonly refresh: (credentialID: Credential.ID) => Effect.Effect<void, AuthorizationError>
readonly connect: {
/** Runs a key method and stores the resulting credential. */
readonly key: (input: {
/** Connector receiving the credential. */
readonly connectorID: ID
/** Key method selected by the caller. */
readonly methodID: MethodID
/** Secret entered by the user. */
readonly key: string
/** Answers to the method's optional prompts. */
readonly inputs: Inputs
/** User-facing label for the stored credential. */
readonly label?: string
}) => Effect.Effect<void, AuthorizationError>
readonly oauth: {
/** Starts a stateful OAuth attempt. */
readonly begin: (input: {
/** Connector being authenticated. */
readonly connectorID: ID
/** OAuth method selected by the caller. */
readonly methodID: MethodID
/** Answers to the method's optional prompts. */
readonly inputs: Inputs
/** User-facing label for the credential created on completion. */
readonly label?: string
}) => Effect.Effect<
Attempt,
AuthorizationError
>
/** Returns the current state of an OAuth attempt. */
readonly status: (attemptID: AttemptID) => Effect.Effect<AttemptStatus>
/** Completes the attempt and stores its credential. */
readonly complete: (input: {
/** Opaque handle returned by `begin`. */
readonly attemptID: AttemptID
/** Authorization code required by attempts in code mode. */
readonly code?: string
}) => Effect.Effect<void, CodeRequiredError | AuthorizationError>
/** Cancels an attempt and releases its resources. */
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
}
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Connector") {}
enableMapSet()
const attemptLifetime = Duration.toMillis(Duration.minutes(10))
const terminalRetention = Duration.toMillis(Duration.minutes(1))
const scrubInterval = Duration.seconds(30)
type AttemptTime = { created: number; expires: number }
type PendingAttempt = {
status: "pending"
completing: boolean
authorization: OAuthAuthorization
connectorID: ID
methodID: MethodID
label?: string
scope: Scope.Closeable
time: AttemptTime
}
type TerminalAttempt = {
status: "complete" | "failed" | "expired"
message?: string
removeAt: number
time: AttemptTime
}
type AttemptEntry = PendingAttempt | TerminalAttempt
export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const credentials = yield* Credential.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const refreshLocks = KeyedMutex.makeUnsafe<Credential.ID>()
const state = State.create<Data, Editor>({
initial: () => ({ connectors: new Map<ID, Entry>() }),
editor: (draft) => ({
list: () => Array.from(draft.connectors.values(), (entry) => entry.connector) as Info[],
get: (id) => draft.connectors.get(id)?.connector as Info | undefined,
update: (id, update) => {
const current = draft.connectors.get(id) ??
castDraft({ connector: new Info({ id, name: id, methods: [] }), implementations: new Map() })
if (!draft.connectors.has(id)) draft.connectors.set(id, current)
update(current.connector)
current.connector.id = id
},
remove: (id) => draft.connectors.delete(id),
method: {
update: (implementation) => {
const current = draft.connectors.get(implementation.connectorID) ??
castDraft({
connector: new Info({ id: implementation.connectorID, name: implementation.connectorID, methods: [] }),
implementations: new Map<MethodID, Implementation>(),
})
if (!draft.connectors.has(implementation.connectorID)) {
draft.connectors.set(implementation.connectorID, current)
}
const index = current.connector.methods.findIndex((method) => method.id === implementation.method.id)
if (index === -1) current.connector.methods.push(castDraft(implementation.method))
else current.connector.methods[index] = castDraft(implementation.method)
current.implementations.set(implementation.method.id, castDraft(implementation))
},
remove: (connectorID, methodID) => {
const current = draft.connectors.get(connectorID)
if (!current) return
const index = current.connector.methods.findIndex((method) => method.id === methodID)
if (index !== -1) current.connector.methods.splice(index, 1)
current.implementations.delete(methodID)
},
},
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause })))
const close = (attemptScope: Scope.Closeable) =>
Scope.close(attemptScope, Exit.void).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
const message = (cause: Cause.Cause<unknown>) => {
const error = Cause.squash(cause)
return error instanceof Error ? error.message : String(error)
}
const settle = Effect.fnUntraced(function* (
attemptID: AttemptID,
exit: Exit.Exit<Credential.Value, unknown>,
) {
const now = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID)
if (!attempt || attempt.status !== "pending") return [undefined, current]
const terminal: TerminalAttempt = Exit.isSuccess(exit)
? { status: "complete", time: attempt.time, removeAt: now + terminalRetention }
: { status: "failed", message: message(exit.cause), time: attempt.time, removeAt: now + terminalRetention }
return [attempt, new Map(current).set(attemptID, terminal)]
})
if (!result) return
if (Exit.isSuccess(exit)) {
yield* credentials.create({
connectorID: result.connectorID,
methodID: result.methodID,
label: result.label,
value: exit.value,
})
}
yield* close(result.scope)
})
const scrub = Effect.fnUntraced(function* () {
const now = yield* Clock.currentTimeMillis
const expired = yield* SynchronizedRef.modify(attempts, (current) => {
const next = new Map(current)
const scopes: Scope.Closeable[] = []
for (const [id, attempt] of current) {
if (attempt.status === "pending" && attempt.time.expires <= now) {
scopes.push(attempt.scope)
next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention })
continue
}
if (attempt.status !== "pending" && attempt.removeAt <= now) next.delete(id)
}
return [scopes, next]
})
yield* Effect.forEach(expired, close, { discard: true })
})
yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope))
return Service.of({
transform: state.transform,
update: state.update,
get: Effect.fn("Connector.get")(function* (id) {
return state.get().connectors.get(id)?.connector
}),
list: Effect.fn("Connector.list")(function* () {
return Array.from(state.get().connectors.values(), (record) => record.connector).toSorted((a, b) =>
a.name.localeCompare(b.name),
)
}),
refresh: Effect.fn("Connector.refresh")(function* (credentialID) {
yield* refreshLocks.withLock(credentialID)(
Effect.gen(function* () {
const credential = yield* credentials.get(credentialID)
if (!credential || credential.value.type !== "oauth") {
return yield* Effect.die(`OAuth credential not found: ${credentialID}`)
}
const implementation = state
.get()
.connectors.get(credential.connectorID)
?.implementations.get(credential.methodID)
if (!implementation || !isOAuthImplementation(implementation) || !implementation.refresh) {
return yield* Effect.die(
`OAuth refresh method not found: ${credential.connectorID}/${credential.methodID}`,
)
}
const value = yield* authorize(implementation.refresh(credential.value))
yield* credentials.update(credential.id, { value })
}),
)
}),
connect: {
key: Effect.fn("Connector.connect.key")(function* (input) {
const method = state.get().connectors.get(input.connectorID)?.implementations.get(input.methodID)
if (!method || !isKeyImplementation(method)) {
return yield* Effect.die(`Key method not found: ${input.connectorID}/${input.methodID}`)
}
const value = yield* authorize(method.authorize(input.key, input.inputs))
yield* credentials.create({
connectorID: input.connectorID,
methodID: input.methodID,
label: input.label,
value,
})
}),
oauth: {
begin: Effect.fn("Connector.connect.oauth.begin")(function* (input) {
const method = state.get().connectors.get(input.connectorID)?.implementations.get(input.methodID)
if (!method || !isOAuthImplementation(method)) {
return yield* Effect.die(`OAuth method not found: ${input.connectorID}/${input.methodID}`)
}
const attemptScope = yield* Scope.fork(scope)
const authorization = yield* authorize(method.authorize(input.inputs)).pipe(
Scope.provide(attemptScope),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)),
)
const id = AttemptID.create()
const created = yield* Clock.currentTimeMillis
const time = { created, expires: created + attemptLifetime }
yield* SynchronizedRef.update(attempts, (current) =>
new Map(current).set(id, {
status: "pending",
completing: authorization.mode === "auto",
authorization,
connectorID: input.connectorID,
methodID: input.methodID,
label: input.label,
scope: attemptScope,
time,
}),
)
if (authorization.mode === "auto") {
yield* authorization.callback.pipe(
Effect.exit,
Effect.flatMap((exit) => settle(id, exit)),
Effect.forkIn(attemptScope, { startImmediately: true }),
)
}
return new Attempt({
attemptID: id,
url: authorization.url,
instructions: authorization.instructions,
mode: authorization.mode,
time,
})
}),
status: Effect.fn("Connector.connect.oauth.status")(function* (attemptID) {
const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID)
if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${attemptID}`)
if (attempt.status === "failed") {
return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time }
}
return { status: attempt.status, time: attempt.time }
}),
complete: Effect.fn("Connector.connect.oauth.complete")(function* (input) {
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(input.attemptID)
if (!match || match.status !== "pending" || match.completing) return [match, current]
if (match.authorization.mode === "code" && input.code === undefined) return [match, current]
return [match, new Map(current).set(input.attemptID, { ...match, completing: true })]
})
if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`)
if (attempt.status !== "pending") return
if (attempt.authorization.mode === "code" && input.code === undefined) {
return yield* new CodeRequiredError({ attemptID: input.attemptID })
}
if (attempt.completing) return yield* Effect.die(`OAuth attempt already completing: ${input.attemptID}`)
const callback =
attempt.authorization.mode === "auto"
? attempt.authorization.callback
: attempt.authorization.callback(input.code as string)
const exit = yield* authorize(callback).pipe(Effect.exit)
yield* settle(input.attemptID, exit)
if (Exit.isFailure(exit)) return yield* exit
}),
cancel: Effect.fn("Connector.connect.oauth.cancel")(function* (attemptID) {
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending") return [undefined, current]
const next = new Map(current)
next.delete(attemptID)
return [match, next]
})
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
}),
},
},
})
}),
)

View file

@ -0,0 +1,9 @@
export * as ConnectorSchema from "./schema"
import { Schema } from "effect"
export const ID = Schema.String.pipe(Schema.brand("Connector.ID"))
export type ID = typeof ID.Type
export const MethodID = Schema.String.pipe(Schema.brand("Connector.MethodID"))
export type MethodID = typeof MethodID.Type

View file

@ -0,0 +1,329 @@
export * as Credential from "./credential"
import { and, asc, eq, ne } from "drizzle-orm"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Database } from "./database/database"
import { ConnectorSchema } from "./connector/schema"
import { EventV2 } from "./event"
import { NonNegativeInt, withStatics } from "./schema"
import { CredentialTable } from "./credential/sql"
import { Identifier } from "./util/identifier"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { DataMigrationTable } from "./data-migration.sql"
import path from "path"
export const ID = Schema.String.pipe(
Schema.brand("Credential.ID"),
withStatics((schema) => ({ create: () => schema.make("cred_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export class OAuth extends Schema.Class<OAuth>("Credential.OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export class Key extends Schema.Class<Key>("Credential.Key")({
type: Schema.Literal("key"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Value = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Value" })
export type Value = Schema.Schema.Type<typeof Value>
const LegacyOAuth = Schema.Struct({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
})
const LegacyKey = Schema.Struct({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
const LegacyValue = Schema.Union([LegacyOAuth, LegacyKey])
export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID,
connectorID: ConnectorSchema.ID,
methodID: ConnectorSchema.MethodID,
label: Schema.String,
value: Value,
}) {}
export const Event = {
Added: EventV2.define({
type: "credential.added",
schema: { credential: Info },
}),
Removed: EventV2.define({
type: "credential.removed",
schema: { credential: Info },
}),
Switched: EventV2.define({
type: "credential.switched",
schema: {
connectorID: ConnectorSchema.ID,
from: Schema.optional(ID),
to: Schema.optional(ID),
},
}),
}
export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly all: () => Effect.Effect<Info[]>
readonly create: (input: {
connectorID: ConnectorSchema.ID
methodID: ConnectorSchema.MethodID
value: Value
label?: string
}) => Effect.Effect<Info>
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
readonly activate: (id: ID) => Effect.Effect<void>
readonly active: (connectorID: ConnectorSchema.ID) => Effect.Effect<Info | undefined>
readonly activeAll: () => Effect.Effect<Map<ConnectorSchema.ID, Info>>
readonly forConnector: (connectorID: ConnectorSchema.ID) => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Credential") {}
export const legacyImportLayer = Layer.effectDiscard(
Effect.gen(function* () {
const { db } = yield* Database.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const name = "credential.auth-json"
if (yield* db.select().from(DataMigrationTable).where(eq(DataMigrationTable.name, name)).get()) return
const raw = yield* fs.readJson(path.join(global.data, "auth.json")).pipe(Effect.option)
if (Option.isNone(raw) || typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)) return
const decode = Schema.decodeUnknownOption(LegacyValue)
const values = Object.entries(raw.value).flatMap(([connectorID, value]) => {
const decoded = decode(value)
if (Option.isNone(decoded)) return []
const credential = decoded.value
const id = ID.create()
const connector = ConnectorSchema.ID.make(connectorID.replace(/\/+$/, ""))
const methodID = ConnectorSchema.MethodID.make(
credential.type === "api" ? "api-key" : connector === ConnectorSchema.ID.make("openai") ? "chatgpt-browser" : "oauth",
)
const next: Value =
credential.type === "api"
? new Key({ type: "key", key: credential.key, metadata: credential.metadata })
: new OAuth({
type: "oauth",
refresh: credential.refresh,
access: credential.access,
expires: credential.expires,
metadata: {
...(credential.accountId ? { accountID: credential.accountId } : {}),
...(credential.enterpriseUrl ? { enterpriseURL: credential.enterpriseUrl } : {}),
},
})
return [{ id, connectorID: connector, methodID, value: next }]
})
yield* db.transaction((tx) =>
Effect.gen(function* () {
for (const item of values) {
if (
yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.connector_id, item.connectorID))
.get()
)
continue
yield* tx.insert(CredentialTable).values({
id: item.id,
connector_id: item.connectorID,
method_id: item.methodID,
label: "Imported",
value: item.value,
active: true,
})
}
yield* tx.insert(DataMigrationTable).values({ name, time_completed: Date.now() }).onConflictDoNothing().run()
}),
)
}).pipe(Effect.orDie),
)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const decodeValue = Schema.decodeUnknownSync(Value)
const info = (row: typeof CredentialTable.$inferSelect) =>
new Info({
id: row.id,
connectorID: row.connector_id,
methodID: row.method_id,
label: row.label,
value: decodeValue(row.value),
})
const activate = Effect.fn("Credential.activate")(function* (id: ID) {
const switched = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!credential || credential.active) return
const current = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(and(eq(CredentialTable.connector_id, credential.connector_id), eq(CredentialTable.active, true)))
.get()
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.connector_id, credential.connector_id))
.run()
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, id)).run()
return { connectorID: credential.connector_id, from: current?.id, to: id }
}),
)
.pipe(Effect.orDie)
if (switched) yield* events.publish(Event.Switched, switched)
})
return Service.of({
get: Effect.fn("Credential.get")(function* (id) {
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
return row ? info(row) : undefined
}),
all: Effect.fn("Credential.all")(function* () {
return (yield* db.select().from(CredentialTable).orderBy(asc(CredentialTable.time_created)).all().pipe(Effect.orDie)).map(
info,
)
}),
active: Effect.fn("Credential.active")(function* (connectorID) {
const row = yield* db
.select()
.from(CredentialTable)
.where(and(eq(CredentialTable.connector_id, connectorID), eq(CredentialTable.active, true)))
.get()
.pipe(Effect.orDie)
return row ? info(row) : undefined
}),
activeAll: Effect.fn("Credential.activeAll")(function* () {
const rows = yield* db.select().from(CredentialTable).where(eq(CredentialTable.active, true)).all().pipe(Effect.orDie)
return new Map(rows.map((row) => [row.connector_id, info(row)]))
}),
forConnector: Effect.fn("Credential.forConnector")(function* (connectorID) {
return (
yield* db
.select()
.from(CredentialTable)
.where(eq(CredentialTable.connector_id, connectorID))
.orderBy(asc(CredentialTable.time_created))
.all()
.pipe(Effect.orDie)
).map(info)
}),
create: Effect.fn("Credential.create")(function* (input) {
const credential = new Info({
id: ID.create(),
connectorID: input.connectorID,
methodID: input.methodID,
label: input.label ?? "default",
value: input.value,
})
const from = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const current = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(and(eq(CredentialTable.connector_id, input.connectorID), eq(CredentialTable.active, true)))
.get()
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.connector_id, input.connectorID))
.run()
yield* tx
.insert(CredentialTable)
.values({
id: credential.id,
connector_id: credential.connectorID,
method_id: credential.methodID,
label: credential.label,
value: credential.value,
active: true,
})
.run()
return current?.id
}),
)
.pipe(Effect.orDie)
yield* events.publish(Event.Added, { credential })
yield* events.publish(Event.Switched, { connectorID: credential.connectorID, from, to: credential.id })
return credential
}),
update: Effect.fn("Credential.update")(function* (id, updates) {
if (!updates.label && !updates.value) return
yield* db
.update(CredentialTable)
.set({ label: updates.label, value: updates.value })
.where(eq(CredentialTable.id, id))
.run()
.pipe(Effect.orDie)
}),
remove: Effect.fn("Credential.remove")(function* (id) {
const removed = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const row = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!row) return
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
if (!row.active) return { credential: info(row) }
const replacement = yield* tx
.select()
.from(CredentialTable)
.where(and(eq(CredentialTable.connector_id, row.connector_id), ne(CredentialTable.id, id)))
.orderBy(asc(CredentialTable.time_created))
.get()
if (replacement) {
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, replacement.id)).run()
}
return {
credential: info(row),
switched: { connectorID: row.connector_id, from: id, to: replacement?.id },
}
}),
)
.pipe(Effect.orDie)
if (!removed) return
yield* events.publish(Event.Removed, { credential: removed.credential })
if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
}),
activate,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provideMerge(
legacyImportLayer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.defaultLayer),
),
),
)

View file

@ -0,0 +1,21 @@
import { sql } from "drizzle-orm"
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { ConnectorSchema } from "../connector/schema"
import type { Credential } from "../credential"
export const CredentialTable = sqliteTable(
"credential",
{
id: text().$type<Credential.ID>().primaryKey(),
connector_id: text().$type<ConnectorSchema.ID>().notNull(),
method_id: text().$type<ConnectorSchema.MethodID>().notNull(),
label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
active: integer({ mode: "boolean" }).notNull().default(false),
...Timestamps,
},
(table) => [
uniqueIndex("credential_connector_active_idx").on(table.connector_id).where(sql`${table.active} = 1`),
],
)

View file

@ -34,5 +34,6 @@ export const migrations = (
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260605003541_add_session_context_snapshot"),
import("./migration/20260605042240_add_context_epoch_agent"),
import("./migration/20260611035744_credential"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,23 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260611035744_credential",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`credential\` (
\`id\` text PRIMARY KEY,
\`connector_id\` text NOT NULL,
\`method_id\` text NOT NULL,
\`label\` text NOT NULL,
\`value\` text NOT NULL,
\`active\` integer DEFAULT false NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(`CREATE UNIQUE INDEX \`credential_connector_active_idx\` ON \`credential\` (\`connector_id\`) WHERE "credential"."active" = 1;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -4,12 +4,13 @@ import { Policy } from "./policy"
import { Config } from "./config"
import { PluginV2 } from "./plugin"
import { Catalog } from "./catalog"
import { Connector } from "./connector"
import { CommandV2 } from "./command"
import { AgentV2 } from "./agent"
import { PluginBoot } from "./plugin/boot"
import { Project } from "./project"
import { EventV2 } from "./event"
import { Auth } from "./auth"
import { Credential } from "./credential"
import { Npm } from "./npm"
import { ModelsDev } from "./models-dev"
import { FSUtil } from "./fs-util"
@ -58,6 +59,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Reference.locationLayer,
PluginV2.locationLayer,
Catalog.locationLayer,
Connector.locationLayer,
CommandV2.locationLayer,
AgentV2.locationLayer,
PluginBoot.locationLayer,
@ -114,7 +116,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
dependencies: [
Project.defaultLayer,
EventV2.defaultLayer,
Auth.defaultLayer,
Credential.defaultLayer,
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,

View file

@ -25,14 +25,6 @@ type HookSpec = {
input: Catalog.Editor
output: {}
}
"account.switched": {
input: {
serviceID: import("./auth").Auth.ServiceID
from?: import("./auth").Auth.ID
to?: import("./auth").Auth.ID
}
output: {}
}
"aisdk.language": {
input: {
model: ModelV2.Info

View file

@ -1,45 +0,0 @@
import { Effect, Scope, Stream } from "effect"
import { EventV2 } from "../event"
import { PluginV2 } from "../plugin"
import { Auth } from "../auth"
// Depending on what account is active, enable matching providers for that
// service
export const AccountPlugin = PluginV2.define({
id: PluginV2.ID.make("account"),
effect: Effect.gen(function* () {
const accounts = yield* Auth.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
yield* events.subscribe(Auth.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) {
const active = yield* accounts.activeAll().pipe(Effect.orDie)
if (active.size === 0) return
for (const item of evt.provider.list()) {
const account = active.get(Auth.ServiceID.make(item.provider.id))
if (!account) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {
via: "account",
service: account.serviceID,
}
if (account.credential.type === "api") {
provider.request.body.apiKey = account.credential.key
Object.assign(provider.request.body, account.credential.metadata ?? {})
}
if (account.credential.type === "oauth") provider.request.body.apiKey = account.credential.access
})
}
}),
"account.switched": Effect.fn(function* () {}),
}
}),
})

View file

@ -79,7 +79,7 @@ Your output must be:
"implement rate limiting" -> Rate limiting implementation
"how do I connect postgres to my API" -> Postgres API connection
"best practices for React hooks" -> React hooks best practices
"@src/auth.ts can you add refresh token support" -> Auth refresh token support
"@src/credential.ts can you add refresh token support" -> Credential refresh token support
"@utils/parser.ts this is broken" -> Parser bug fix
"look at @config.json" -> Config review
"@App.tsx add dark mode toggle" -> Dark mode toggle in App

View file

@ -1,7 +1,8 @@
export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect"
import { Auth } from "../auth"
import { Credential } from "../credential"
import { Connector } from "../connector"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
@ -17,7 +18,6 @@ import { Location } from "../location"
import { ModelsDev } from "../models-dev"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { AccountPlugin } from "./account"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { SkillPlugin } from "./skill"
@ -33,7 +33,8 @@ type Plugin = {
effect: PluginV2.Effect<
| Catalog.Service
| CommandV2.Service
| Auth.Service
| Credential.Service
| Connector.Service
| AgentV2.Service
| Npm.Service
| EventV2.Service
@ -60,7 +61,8 @@ export const layer = Layer.effect(
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const plugin = yield* PluginV2.Service
const accounts = yield* Auth.Service
const credentials = yield* Credential.Service
const connectors = yield* Connector.Service
const agents = yield* AgentV2.Service
const config = yield* Config.Service
const location = yield* Location.Service
@ -79,7 +81,8 @@ export const layer = Layer.effect(
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(CommandV2.Service, commands),
Effect.provideService(Auth.Service, accounts),
Effect.provideService(Credential.Service, credentials),
Effect.provideService(Connector.Service, connectors),
Effect.provideService(AgentV2.Service, agents),
Effect.provideService(Config.Service, config),
Effect.provideService(Location.Service, location),
@ -97,7 +100,6 @@ export const layer = Layer.effect(
const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AccountPlugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
@ -125,6 +127,7 @@ export const layer = Layer.effect(
)
export const locationLayer = layer.pipe(
Layer.provideMerge(Connector.locationLayer),
Layer.provideMerge(Catalog.locationLayer),
Layer.provideMerge(CommandV2.locationLayer),
Layer.provideMerge(Config.locationLayer),

View file

@ -1,5 +1,7 @@
import { DateTime, Effect, Scope, Stream } from "effect"
import { Catalog } from "../catalog"
import { Connector } from "../connector"
import { Credential } from "../credential"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ModelRequest } from "../model-request"
@ -54,12 +56,30 @@ export const ModelsDevPlugin = PluginV2.define({
id: PluginV2.ID.make("models-dev"),
effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
const connectors = yield* Connector.Service
const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const transform = yield* catalog.transform()
const connectorTransform = yield* connectors.transform()
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
const data = yield* modelsDev.get()
yield* connectorTransform((connectors) => {
for (const item of Object.values(data)) {
if (item.env.length === 0) continue
const connectorID = Connector.ID.make(item.id)
connectors.update(connectorID, (connector) => (connector.name = item.name))
connectors.method.update({
connectorID,
method: new Connector.KeyMethod({
id: Connector.MethodID.make("api-key"),
type: "key",
label: "API Key",
}),
authorize: (key: string) => Effect.succeed(new Credential.Key({ type: "key", key })),
})
}
})
yield* transform((catalog) => {
for (const item of Object.values(data)) {
const providerID = ProviderV2.ID.make(item.id)

View file

@ -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")
// AccountPlugin copies CLI prompt metadata into options. The prompt stores the
// Credential projection copies key 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")

View file

@ -24,9 +24,12 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
if (evt.model.providerID !== providerID) return
if (evt.package !== "@ai-sdk/openai-compatible") return
if (!hasWorkersEndpoint(evt.model.api)) return
const accountId = resolveAccountId(evt.options)
if (!hasWorkersEndpoint(evt.model.api) && !accountId) return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible(sdkOptions(evt.options) as any)
evt.sdk = mod.createOpenAICompatible(
sdkOptions({ ...evt.options, baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined) }) as any,
)
}),
"aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return

View file

@ -0,0 +1,251 @@
import { createServer } from "node:http"
import { Deferred, Effect } from "effect"
import { Connector } from "../../connector"
import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const pollingSafetyMargin = 3000
type Pkce = {
verifier: string
challenge: string
}
type TokenResponse = {
id_token: string
access_token: string
refresh_token: string
expires_in?: number
}
type Claims = {
chatgpt_account_id?: string
organizations?: Array<{ id: string }>
"https://api.openai.com/auth"?: { chatgpt_account_id?: string }
}
export const browser = {
connectorID: Connector.ID.make("openai"),
method: new Connector.OAuthMethod({
id: Connector.MethodID.make("chatgpt-browser"),
type: "oauth",
label: "ChatGPT Pro/Plus (browser)",
}),
authorize: () =>
Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE)
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const code = yield* Deferred.make<string, Error>()
const redirect = `http://localhost:${callbackPort}/auth/callback`
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (url.pathname !== "/auth/callback") {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
const value = url.searchParams.get("code")
if (error) {
Effect.runFork(Deferred.fail(code, new Error(error)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
return
}
if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, "localhost", () => resume(Effect.void))
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
server.close()
}),
)
return {
mode: "auto" as const,
url: authorizeURL(redirect, pkce, state),
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) => exchange(value, redirect, pkce)),
Effect.map(credential),
),
}
}),
refresh: (value) => refresh(value),
} satisfies Connector.OAuthImplementation
export const headless = {
connectorID: Connector.ID.make("openai"),
method: new Connector.OAuthMethod({
id: Connector.MethodID.make("chatgpt-headless"),
type: "oauth",
label: "ChatGPT Pro/Plus (headless)",
}),
authorize: () =>
Effect.gen(function* () {
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
`${issuer}/api/accounts/deviceauth/usercode`,
{
method: "POST",
headers: headers("application/json"),
body: JSON.stringify({ client_id: clientID }),
},
)
const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
return {
mode: "auto" as const,
url: `${issuer}/codex/device`,
instructions: `Enter code: ${device.user_code}`,
callback: Effect.gen(function* () {
while (true) {
const response = yield* Effect.tryPromise({
try: (signal) =>
fetch(`${issuer}/api/accounts/deviceauth/token`, {
method: "POST",
headers: headers("application/json"),
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
signal,
}),
catch: (cause) => cause,
})
if (response.ok) {
const data = (yield* Effect.promise(() => response.json())) as {
authorization_code: string
code_verifier: string
}
return credential(
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
verifier: data.code_verifier,
challenge: "",
}),
)
}
if (response.status !== 403 && response.status !== 404) {
return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
}
yield* Effect.sleep(interval + pollingSafetyMargin)
}
}),
}
}),
refresh: (value) => refresh(value),
} satisfies Connector.OAuthImplementation
function headers(contentType: string) {
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
}
function exchange(code: string, redirect: string, pkce: Pkce) {
return request<TokenResponse>(`${issuer}/oauth/token`, {
method: "POST",
headers: headers("application/x-www-form-urlencoded"),
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirect,
client_id: clientID,
code_verifier: pkce.verifier,
}).toString(),
})
}
function refresh(value: Credential.OAuth) {
return request<TokenResponse>(`${issuer}/oauth/token`, {
method: "POST",
headers: headers("application/x-www-form-urlencoded"),
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: value.refresh,
client_id: clientID,
}).toString(),
}).pipe(
Effect.map((tokens) => {
const next = credential(tokens)
return new Credential.OAuth({
...next,
metadata: next.metadata ?? value.metadata,
})
}),
)
}
function request<A>(url: string, init: RequestInit) {
return Effect.tryPromise({
try: async (signal) => {
const response = await fetch(url, { ...init, signal })
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
return response.json() as Promise<A>
},
catch: (cause) => cause,
})
}
function credential(tokens: TokenResponse) {
const accountID = extractAccountID(tokens)
return new Credential.OAuth({
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
metadata: accountID ? { accountID } : undefined,
})
}
async function generatePKCE(): Promise<Pkce> {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
return { verifier, challenge }
}
function base64UrlEncode(buffer: ArrayBuffer) {
return Buffer.from(buffer).toString("base64url")
}
function authorizeURL(redirect: string, pkce: Pkce, state: string) {
return `${issuer}/oauth/authorize?${new URLSearchParams({
response_type: "code",
client_id: clientID,
redirect_uri: redirect,
scope: "openid profile email offline_access",
code_challenge: pkce.challenge,
code_challenge_method: "S256",
id_token_add_organizations: "true",
codex_cli_simplified_flow: "true",
state,
originator: "opencode",
})}`
}
function extractAccountID(tokens: TokenResponse) {
return claim(tokens.id_token) ?? claim(tokens.access_token)
}
function claim(token: string) {
const part = token.split(".")[1]
if (!part) return
try {
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
return (
claims.chatgpt_account_id ??
claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
claims.organizations?.[0]?.id
)
} catch {
return
}
}
const successPage = "<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
const errorPage = (message: string) =>
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`

View file

@ -2,10 +2,17 @@ import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
import { Connector } from "../../connector"
import { browser, headless } from "./openai-auth"
export const OpenAIPlugin = PluginV2.define({
id: PluginV2.ID.make("openai"),
effect: Effect.gen(function* () {
const connectors = yield* Connector.Service
yield* connectors.update((editor) => {
editor.method.update(browser)
editor.method.update(headless)
})
return {
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return

View file

@ -14,7 +14,7 @@ export const OpencodePlugin = PluginV2.define({
process.env.OPENCODE_API_KEY ||
item.provider.env.some((env) => process.env[env]) ||
item.provider.request.body.apiKey ||
(item.provider.enabled && item.provider.enabled.via === "account"),
(item.provider.enabled && item.provider.enabled.via === "credential"),
)
evt.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.request.body.apiKey = "public"

View file

@ -2,6 +2,7 @@ export * as ProviderV2 from "./provider"
import { withStatics } from "./schema"
import { Schema } from "effect"
import { Credential } from "./credential"
export const ID = Schema.String.pipe(
Schema.brand("ProviderV2.ID"),
@ -54,8 +55,8 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
name: Schema.String,
}),
Schema.Struct({
via: Schema.Literal("account"),
service: Schema.String,
via: Schema.Literal("credential"),
credentialID: Credential.ID,
}),
Schema.Struct({
via: Schema.Literal("custom"),

View file

@ -1,284 +0,0 @@
import path from "path"
import { describe, expect } from "bun:test"
import { produce } from "immer"
import { Effect, Fiber, Layer, Option, Stream } from "effect"
import { Auth } from "@opencode-ai/core/auth"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
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.locationLayer.pipe(Layer.provide(EventV2.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.Editor {
return {
provider: {
list: () => records,
get: (providerID) => records.find((item) => item.provider.id === providerID),
update: (providerID, fn) => {
const record = records.find((item) => item.provider.id === providerID)
const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
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.request.body.apiKey === "string" ? provider.request.body.apiKey : undefined,
})
},
remove: (providerID) => {
const index = records.findIndex((item) => item.provider.id === providerID)
if (index !== -1) records.splice(index, 1)
},
},
model: {
get: () => undefined,
update: () => {},
remove: () => {},
default: {
get: () => undefined,
set: () => {},
},
},
}
}
function testLayer(dir: string) {
return Auth.layer.pipe(
Layer.provide(FSUtil.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("Auth", () => {
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* Auth.Service
const eventSvc = yield* EventV2.Service
const addedFiber = yield* eventSvc
.subscribe(Auth.Event.Added)
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
const switchedFiber = yield* eventSvc
.subscribe(Auth.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
const removedFiber = yield* eventSvc
.subscribe(Auth.Event.Removed)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.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: Auth.ServiceID.make("provider"),
credential: new Auth.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: Auth.ServiceID.make("provider"), from: undefined, to: first.id },
{ serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id },
{ serviceID: Auth.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* Auth.Service
const eventSvc = yield* EventV2.Service
const switchedFiber = yield* eventSvc
.subscribe(Auth.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }),
})
const second = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }),
})
const third = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.ApiKeyCredential({ type: "api", key: "third-key" }),
})
expect(first).toBeDefined()
expect(second).toBeDefined()
expect(third).toBeDefined()
if (!first || !second || !third) return
expect((yield* accounts.active(Auth.ServiceID.make("provider")))?.id).toBe(third.id)
expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
{ serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id },
{ serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id },
{ serviceID: Auth.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* Auth.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({
transform: () => Effect.die("unexpected catalog.transform"),
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>()),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
})
const eventSvc = yield* EventV2.Service
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(Auth.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: Auth.ServiceID.make("provider"),
credential: new Auth.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: Auth.ServiceID.make("provider") },
apiKey: "first-key",
},
])
updates.length = 0
const second = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.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: Auth.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: Auth.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: Auth.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))),
),
),
)
})

View file

@ -1,6 +1,8 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Connector } from "@opencode-ai/core/connector"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
@ -17,10 +19,58 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const it = testEffect(
Catalog.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)),
Catalog.locationLayer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(Layer.mock(Credential.Service)({ activeAll: () => Effect.succeed(new Map()) })),
),
)
describe("CatalogV2", () => {
it.effect("projects active credentials without rebuilding catalog state", () => {
const connectorID = Connector.ID.make("test")
const methodID = Connector.MethodID.make("api-key")
const first = new Credential.Info({
id: Credential.ID.create(),
connectorID,
methodID,
label: "First",
value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
})
const second = new Credential.Info({
id: Credential.ID.create(),
connectorID,
methodID,
label: "Second",
value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
})
let active = first
const layer = Catalog.locationLayer.pipe(
Layer.fresh,
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(
Layer.mock(Credential.Service)({ activeAll: () => Effect.succeed(new Map([[connectorID, active]])) }),
),
)
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
const transform = yield* catalog.transform()
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
enabled: { via: "credential", credentialID: first.id },
request: { body: { apiKey: "first", tenant: "one" } },
})
active = second
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
enabled: { via: "credential", credentialID: second.id },
request: { body: { apiKey: "second", tenant: "two" } },
})
}).pipe(Effect.provide(layer))
})
it.effect("normalizes provider baseURL into api url", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service

View file

@ -0,0 +1,403 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Exit, Layer, Scope } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Connector } from "@opencode-ai/core/connector"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect"
const layer = Connector.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
create: () => Effect.die("unexpected credential creation"),
}),
),
)
function connectionLayer(
created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}>,
) {
return Connector.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
create: (input) =>
Effect.sync(() => {
created.push(input)
return new Credential.Info({ id: Credential.ID.create(), ...input, label: input.label ?? "default" })
}),
}),
),
)
}
describe("Connector", () => {
it.effect("registers connectors through the editor", () =>
Effect.gen(function* () {
const connectors = yield* Connector.Service
const scope = yield* Scope.fork(yield* Scope.Scope)
const openai = Connector.ID.make("openai")
yield* connectors
.update((editor) => editor.update(openai, (connector) => (connector.name = "OpenAI")))
.pipe(Scope.provide(scope))
expect(yield* connectors.get(openai)).toEqual(
new Connector.Info({ id: openai, name: "OpenAI", methods: [] }),
)
yield* Scope.close(scope, Exit.void)
expect(yield* connectors.get(openai)).toBeUndefined()
}).pipe(Effect.provide(layer)),
)
it.effect("reveals the previous registration when an override closes", () =>
Effect.gen(function* () {
const connectors = yield* Connector.Service
const id = Connector.ID.make("openai")
const first = yield* Scope.fork(yield* Scope.Scope)
const second = yield* Scope.fork(yield* Scope.Scope)
yield* connectors
.update((editor) => editor.update(id, (connector) => (connector.name = "OpenAI")))
.pipe(Scope.provide(first))
yield* connectors
.update((editor) => editor.update(id, (connector) => (connector.name = "OpenAI Override")))
.pipe(Scope.provide(second))
expect((yield* connectors.get(id))?.name).toBe("OpenAI Override")
yield* Scope.close(second, Exit.void)
expect((yield* connectors.get(id))?.name).toBe("OpenAI")
expect((yield* connectors.list()).map((connector) => connector.id)).toEqual([id])
}).pipe(Effect.provide(layer)),
)
it.effect("registers and overrides methods independently", () =>
Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("chatgpt")
const first = yield* Scope.fork(yield* Scope.Scope)
const second = yield* Scope.fork(yield* Scope.Scope)
const authorize = () =>
Effect.succeed({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.never,
})
yield* connectors
.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
authorize,
}),
)
.pipe(Scope.provide(first))
yield* connectors
.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }),
authorize,
}),
)
.pipe(Scope.provide(second))
expect((yield* connectors.get(connectorID))?.name).toBe("openai")
expect((yield* connectors.get(connectorID))?.methods[0]?.label).toBe("ChatGPT Override")
yield* Scope.close(second, Exit.void)
expect((yield* connectors.get(connectorID))?.methods[0]?.label).toBe("ChatGPT")
expect((yield* connectors.get(connectorID))?.methods.map((method) => method.id)).toEqual([methodID])
}).pipe(Effect.provide(layer)),
)
it.effect("connects with a key and stores the credential", () => {
const created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}> = []
return Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("api-key")
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.KeyMethod({ id: methodID, type: "key", label: "API key" }),
authorize: (key, inputs) =>
Effect.succeed(
new Credential.Key({ type: "key", key, metadata: { organization: inputs.organization ?? "" } }),
),
}),
)
yield* connectors.connect.key({
connectorID,
methodID,
key: "secret",
inputs: { organization: "acme" },
label: "Work",
})
expect(created).toEqual([
{
connectorID,
methodID,
label: "Work",
value: new Credential.Key({ type: "key", key: "secret", metadata: { organization: "acme" } }),
},
])
}).pipe(Effect.provide(connectionLayer(created)))
})
it.effect("refreshes OAuth with the originating method", () => {
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("chatgpt")
const credentialID = Credential.ID.create()
const current = new Credential.OAuth({
type: "oauth",
access: "old-access",
refresh: "old-refresh",
expires: 1,
metadata: { accountID: "account" },
})
const updated: Array<{ id: Credential.ID; value: Credential.Value }> = []
const refreshLayer = Connector.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
get: () =>
Effect.succeed(
new Credential.Info({
id: credentialID,
connectorID,
methodID,
label: "Personal",
value: current,
}),
),
update: (id, input) =>
Effect.sync(() => {
if (input.value) updated.push({ id, value: input.value })
}),
}),
),
)
return Effect.gen(function* () {
const connectors = yield* Connector.Service
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
authorize: () => Effect.die("unexpected authorization"),
refresh: (value) =>
Effect.succeed(
new Credential.OAuth({
type: "oauth",
access: "new-access",
refresh: "new-refresh",
expires: 2,
metadata: value.metadata,
}),
),
}),
)
yield* connectors.refresh(credentialID)
expect(updated).toEqual([
{
id: credentialID,
value: new Credential.OAuth({
type: "oauth",
access: "new-access",
refresh: "new-refresh",
expires: 2,
metadata: { accountID: "account" },
}),
},
])
}).pipe(Effect.provide(refreshLayer))
})
it.effect("completes code OAuth once and stores the credential", () => {
const created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}> = []
return Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("chatgpt")
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
authorize: () =>
Effect.succeed({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: (code: string) =>
Effect.succeed(
new Credential.OAuth({
type: "oauth",
access: "access",
refresh: "refresh",
expires: 1,
metadata: { code },
}),
),
}),
}),
)
const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {}, label: "Personal" })
expect(attempt.mode).toBe("code")
yield* connectors.connect.oauth.complete({ attemptID: attempt.attemptID, code: "1234" })
expect(created[0]).toEqual({
connectorID,
methodID,
label: "Personal",
value: new Credential.OAuth({
type: "oauth",
access: "access",
refresh: "refresh",
expires: 1,
metadata: { code: "1234" },
}),
})
}).pipe(Effect.provide(connectionLayer(created)))
})
it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => {
const created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}> = []
return Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("chatgpt")
let closed = false
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: () => Effect.die("unexpected callback"),
}),
),
}),
)
const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {} })
expect(yield* connectors.connect.oauth.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
Connector.CodeRequiredError,
)
expect(closed).toBe(false)
yield* connectors.connect.oauth.cancel(attempt.attemptID)
expect(closed).toBe(true)
expect(created).toEqual([])
}).pipe(Effect.provide(connectionLayer(created)))
})
it.effect("completes auto OAuth in the background", () => {
const created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}> = []
return Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("browser")
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
authorize: () =>
Effect.succeed({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.succeed(
new Credential.OAuth({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }),
),
}),
}),
)
const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {} })
yield* Effect.yieldNow
expect(yield* connectors.connect.oauth.status(attempt.attemptID)).toEqual({
status: "complete",
time: attempt.time,
})
expect(created).toHaveLength(1)
}).pipe(Effect.provide(connectionLayer(created)))
})
it.effect("expires abandoned OAuth attempts", () => {
const created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}> = []
return Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("browser")
let closed = false
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.never,
}),
),
}),
)
const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {} })
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
yield* TestClock.adjust(Duration.minutes(10))
yield* Effect.yieldNow
expect(yield* connectors.connect.oauth.status(attempt.attemptID)).toEqual({
status: "expired",
time: attempt.time,
})
expect(closed).toBe(true)
expect(created).toEqual([])
}).pipe(Effect.provide(connectionLayer(created)))
})
})

View file

@ -0,0 +1,207 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Stream } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Connector } from "@opencode-ai/core/connector"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))
function testLayer(directory: string) {
return Credential.layer.pipe(
Layer.fresh,
Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)),
Layer.provideMerge(EventV2.defaultLayer),
)
}
describe("Credential", () => {
it.live("imports supported legacy auth.json credentials once", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, "auth.json"),
JSON.stringify({
openai: {
type: "oauth",
refresh: "refresh",
access: "access",
expires: 123,
accountId: "account",
},
azure: { type: "api", key: "key", metadata: { resourceName: "resource" } },
ignored: { type: "wellknown", key: "TOKEN", token: "secret" },
}),
),
)
const database = Database.layerFromPath(path.join(tmp.path, "credential.db")).pipe(Layer.fresh)
const global = Global.layerWith({ data: tmp.path })
const importer = Credential.legacyImportLayer.pipe(
Layer.provide(database),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(global),
)
const credentials = Credential.layer.pipe(
Layer.provide(database),
Layer.provide(EventV2.defaultLayer),
Layer.provideMerge(importer),
)
const result = yield* Effect.gen(function* () {
const service = yield* Credential.Service
return yield* service.all()
}).pipe(Effect.provide(credentials), Effect.scoped)
expect(result).toHaveLength(2)
expect(result).toContainEqual(
expect.objectContaining({
connectorID: Connector.ID.make("openai"),
methodID: Connector.MethodID.make("chatgpt-browser"),
label: "Imported",
value: expect.objectContaining({
type: "oauth",
refresh: "refresh",
access: "access",
expires: 123,
metadata: { accountID: "account" },
}),
}),
)
expect(result).toContainEqual(
expect.objectContaining({
connectorID: Connector.ID.make("azure"),
methodID: Connector.MethodID.make("api-key"),
value: expect.objectContaining({ type: "key", key: "key", metadata: { resourceName: "resource" } }),
}),
)
yield* importer.pipe(Layer.build, Effect.scoped)
const after = yield* Effect.gen(function* () {
return yield* (yield* Credential.Service).all()
}).pipe(Effect.provide(credentials), Effect.scoped)
expect(after).toHaveLength(2)
}),
),
),
)
it.live("emits credential lifecycle events", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const eventSvc = yield* EventV2.Service
const addedFiber = yield* eventSvc
.subscribe(Credential.Event.Added)
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
const switchedFiber = yield* eventSvc
.subscribe(Credential.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
const removedFiber = yield* eventSvc
.subscribe(Credential.Event.Removed)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* credentials.create({
connectorID: Connector.ID.make("lifecycle"),
methodID: Connector.MethodID.make("key"),
value: new Credential.Key({ type: "key", key: "raw-key" }),
})
expect(first).toBeDefined()
if (!first) return
expect(first.label).toBe("default")
expect(first.value.type).toBe("key")
if (first.value.type === "key") expect(first.value.key).toBe("raw-key")
yield* credentials.update(first.id, { label: "keep" })
const updated = yield* credentials.get(first.id)
expect(updated?.label).toBe("keep")
expect(updated?.value.type).toBe("key")
if (updated?.value.type === "key") expect(updated.value.key).toBe("raw-key")
const second = yield* credentials.create({
connectorID: Connector.ID.make("lifecycle"),
methodID: Connector.MethodID.make("key"),
value: new Credential.Key({ type: "key", key: "second-key" }),
})
expect(second).toBeDefined()
if (!second) return
yield* credentials.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.credential.id)).toEqual([first.id, second.id])
expect(switched.map((event) => event.data)).toEqual([
{ connectorID: Connector.ID.make("lifecycle"), from: undefined, to: first.id },
{ connectorID: Connector.ID.make("lifecycle"), from: first.id, to: second.id },
{ connectorID: Connector.ID.make("lifecycle"), from: second.id, to: first.id },
])
expect(removed[0]?.data.credential.id).toBe(second.id)
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("always switches to newly created credentials", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const eventSvc = yield* EventV2.Service
const switchedFiber = yield* eventSvc
.subscribe(Credential.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* credentials.create({
connectorID: Connector.ID.make("switch"),
methodID: Connector.MethodID.make("key"),
value: new Credential.Key({ type: "key", key: "first-key" }),
})
const second = yield* credentials.create({
connectorID: Connector.ID.make("switch"),
methodID: Connector.MethodID.make("key"),
value: new Credential.Key({ type: "key", key: "second-key" }),
})
const third = yield* credentials.create({
connectorID: Connector.ID.make("switch"),
methodID: Connector.MethodID.make("key"),
value: new Credential.Key({ type: "key", key: "third-key" }),
})
expect(first).toBeDefined()
expect(second).toBeDefined()
expect(third).toBeDefined()
if (!first || !second || !third) return
expect((yield* credentials.active(Connector.ID.make("switch")))?.id).toBe(third.id)
expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
{ connectorID: Connector.ID.make("switch"), from: undefined, to: first.id },
{ connectorID: Connector.ID.make("switch"), from: first.id, to: second.id },
{ connectorID: Connector.ID.make("switch"), from: second.id, to: third.id },
])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
})

View file

@ -13,7 +13,8 @@ import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolDefinitions } from "./lib/tool"
import { FSUtil } from "../src/fs-util"
import { Auth } from "../src/auth"
import { Credential } from "../src/credential"
import { Database } from "../src/database/database"
import { EventV2 } from "../src/event"
import { Global } from "../src/global"
import { ModelsDev } from "../src/models-dev"
@ -33,7 +34,10 @@ const it = testEffect(
Layer.mergeAll(
Project.defaultLayer,
EventV2.defaultLayer,
Auth.defaultLayer,
Credential.layer.pipe(
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
Layer.provide(EventV2.defaultLayer),
),
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,

View file

@ -0,0 +1,14 @@
{
"acme": {
"id": "acme",
"name": "Acme",
"env": ["ACME_API_KEY"],
"models": {}
},
"local": {
"id": "local",
"name": "Local",
"env": [],
"models": {}
}
}

View file

@ -0,0 +1,70 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Connector } from "@opencode-ai/core/connector"
import { Credential } from "@opencode-ai/core/credential"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Location } from "@opencode-ai/core/location"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
const events = EventV2.defaultLayer
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
)
const plugins = PluginV2.layer.pipe(Layer.provide(events))
const policy = Policy.layer.pipe(Layer.provide(locationLayer))
const credentials = Credential.layer.pipe(
Layer.provide(Database.layerFromPath(":memory:")),
Layer.provide(events),
)
const catalog = Catalog.layer.pipe(
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, credentials)),
)
const connectors = Connector.locationLayer.pipe(Layer.provide(credentials), Layer.provide(events))
const layer = Layer.mergeAll(catalog, connectors, credentials, events, locationLayer, plugins)
const it = testEffect(layer)
describe("ModelsDevPlugin", () => {
it.effect("registers key connectors for providers with environment variables", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
path: Flag.OPENCODE_MODELS_PATH,
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
}
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
return previous
}),
() =>
Effect.gen(function* () {
yield* ModelsDevPlugin.effect
const connectors = yield* Connector.Service
expect(yield* connectors.list()).toEqual([
new Connector.Info({
id: Connector.ID.make("acme"),
name: "Acme",
methods: [
new Connector.KeyMethod({ id: Connector.MethodID.make("api-key"), type: "key", label: "API Key" }),
],
}),
])
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
(previous) =>
Effect.sync(() => {
Flag.OPENCODE_MODELS_PATH = previous.path
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
}),
),
)
})

View file

@ -1,11 +1,12 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Auth } from "@opencode-ai/core/auth"
import { Credential } from "@opencode-ai/core/credential"
import { Connector } from "@opencode-ai/core/connector"
import { Database } from "@opencode-ai/core/database/database"
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 { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -15,7 +16,12 @@ import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provi
const itWithAccount = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(Auth.defaultLayer),
Layer.provideMerge(
Credential.layer.pipe(
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
Layer.provide(EventV2.defaultLayer),
),
),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
@ -74,26 +80,17 @@ describe("AzurePlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const accounts = yield* Auth.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
yield* accounts.create({
serviceID: Auth.ServiceID.make("azure"),
credential: new Auth.ApiKeyCredential({
type: "api",
yield* credentials.create({
connectorID: Connector.ID.make("azure"),
methodID: Connector.MethodID.make("api-key"),
value: new Credential.Key({
type: "key",
key: "key",
metadata: { resourceName: "from-account" },
}),
})
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(Auth.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
})
yield* plugin.add(AzurePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {

View file

@ -1,12 +1,13 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Auth } from "@opencode-ai/core/auth"
import { Credential } from "@opencode-ai/core/credential"
import { Connector } from "@opencode-ai/core/connector"
import { Database } from "@opencode-ai/core/database/database"
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 { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -16,7 +17,12 @@ import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper
const itWithAccount = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(Auth.defaultLayer),
Layer.provideMerge(
Credential.layer.pipe(
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
Layer.provide(EventV2.defaultLayer),
),
),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
@ -128,26 +134,17 @@ describe("CloudflareWorkersAIPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const accounts = yield* Auth.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
yield* accounts.create({
serviceID: Auth.ServiceID.make("cloudflare-workers-ai"),
credential: new Auth.ApiKeyCredential({
type: "api",
yield* credentials.create({
connectorID: Connector.ID.make("cloudflare-workers-ai"),
methodID: Connector.MethodID.make("api-key"),
value: new Credential.Key({
type: "key",
key: "account-key",
metadata: { accountId: "account-acct" },
}),
})
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(Auth.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
})
yield* plugin.add(CloudflareWorkersAIPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) =>
@ -155,10 +152,9 @@ describe("CloudflareWorkersAIPlugin", () => {
provider.api = { type: "aisdk", package: "test-provider" }
}),
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/account-acct/ai/v1",
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).request.body).toMatchObject({
apiKey: "account-key",
accountId: "account-acct",
})
}),
),

View file

@ -1,11 +1,12 @@
import { describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect"
import { Auth } from "@opencode-ai/core/auth"
import { Credential } from "@opencode-ai/core/credential"
import { Connector } from "@opencode-ai/core/connector"
import { Database } from "@opencode-ai/core/database/database"
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 { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -30,7 +31,12 @@ void mock.module("gitlab-ai-provider", () => ({
const itWithAccount = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(Auth.defaultLayer),
Layer.provideMerge(
Credential.layer.pipe(
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
Layer.provide(EventV2.defaultLayer),
),
),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/") }))),
@ -165,21 +171,12 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const accounts = yield* Auth.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
yield* accounts.create({
serviceID: Auth.ServiceID.make("gitlab"),
credential: new Auth.ApiKeyCredential({ type: "api", key: "account-token" }),
})
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(Auth.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
yield* credentials.create({
connectorID: Connector.ID.make("gitlab"),
methodID: Connector.MethodID.make("api-key"),
value: new Credential.Key({ type: "key", key: "account-token" }),
})
yield* plugin.add(GitLabPlugin)
const transform = yield* catalog.transform()
@ -208,27 +205,18 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const accounts = yield* Auth.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
yield* accounts.create({
serviceID: Auth.ServiceID.make("gitlab"),
credential: new Auth.OAuthCredential({
yield* credentials.create({
connectorID: Connector.ID.make("gitlab"),
methodID: Connector.MethodID.make("oauth"),
value: new Credential.OAuth({
type: "oauth",
refresh: "refresh-token",
access: "account-oauth-token",
expires: 9999999999999,
}),
})
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(Auth.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
})
yield* plugin.add(GitLabPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))

View file

@ -3,6 +3,8 @@ 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 { Connector } from "@opencode-ai/core/connector"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
@ -46,8 +48,15 @@ export const catalogLayer = Layer.succeed(
}),
)
const connectors = Connector.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(Layer.mock(Credential.Service)({ create: () => Effect.die("unexpected credential creation") })),
)
export const it = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(connectors),
Layer.provideMerge(Layer.mock(Credential.Service)({ activeAll: () => Effect.succeed(new Map()) })),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(npmLayer),

View file

@ -1,17 +1,44 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Connector } from "@opencode-ai/core/connector"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider } from "./provider-helper"
function add(plugin: PluginV2.Interface, connectors: Connector.Interface) {
return plugin.add({
...OpenAIPlugin,
effect: OpenAIPlugin.effect.pipe(Effect.provideService(Connector.Service, connectors)),
})
}
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* add(plugin, yield* Connector.Service)
expect((yield* (yield* Connector.Service).get(Connector.ID.make("openai")))?.methods).toEqual([
new Connector.OAuthMethod({
id: Connector.MethodID.make("chatgpt-browser"),
type: "oauth",
label: "ChatGPT Pro/Plus (browser)",
}),
new Connector.OAuthMethod({
id: Connector.MethodID.make("chatgpt-headless"),
type: "oauth",
label: "ChatGPT Pro/Plus (headless)",
}),
])
}),
)
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenAIPlugin)
yield* add(plugin, yield* Connector.Service)
const result = yield* plugin.trigger(
"aisdk.sdk",
{
@ -28,7 +55,7 @@ describe("OpenAIPlugin", () => {
it.effect("ignores non-OpenAI SDK packages", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
yield* plugin.add(OpenAIPlugin)
yield* add(plugin, yield* Connector.Service)
const result = yield* plugin.trigger(
"aisdk.sdk",
{ model: model("openai", "gpt-5"), package: "@ai-sdk/openai-compatible", options: { name: "openai" } },
@ -42,7 +69,7 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(OpenAIPlugin)
yield* add(plugin, yield* Connector.Service)
const result = yield* plugin.trigger(
"aisdk.language",
{
@ -63,7 +90,7 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const calls: string[] = []
yield* plugin.add(OpenAIPlugin)
yield* add(plugin, yield* Connector.Service)
const result = yield* plugin.trigger(
"aisdk.language",
{ model: model("anthropic", "gpt-5"), sdk: fakeSelectorSdk(calls), options: {} },
@ -78,7 +105,7 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin)
yield* add(plugin, yield* Connector.Service)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } })
@ -97,7 +124,7 @@ describe("OpenAIPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin)
yield* add(plugin, yield* Connector.Service)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("custom-openai")

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
@ -161,7 +162,9 @@ describe("OpencodePlugin", () => {
yield* plugin.add(OpencodePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", { enabled: { via: "account", service: "opencode" } })
const item = provider("opencode", {
enabled: { via: "credential", credentialID: Credential.ID.make("credential") },
})
catalog.provider.update(item.id, (draft) => {
draft.enabled = item.enabled
})

View file

@ -650,6 +650,49 @@ const scenarios: Scenario[] = [
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
http.protected.get("/api/connector", "v2.connector.list").json(200, locationData(array)),
http.protected
.get("/api/connector/{connectorID}", "v2.connector.get")
.at((ctx) => ({ path: route("/api/connector/{connectorID}", { connectorID: "missing" }), headers: ctx.headers() }))
.json(200, object),
http.protected
.post("/api/connector/{connectorID}/connect/key", "v2.connector.connect.key")
.at((ctx) => ({
path: route("/api/connector/{connectorID}/connect/key", { connectorID: "missing" }),
headers: ctx.headers(),
body: { methodID: "missing", key: "test", inputs: {} },
}))
.status(500, undefined, "status"),
http.protected
.post("/api/connector/{connectorID}/connect/oauth", "v2.connector.connect.oauth.begin")
.at((ctx) => ({
path: route("/api/connector/{connectorID}/connect/oauth", { connectorID: "missing" }),
headers: ctx.headers(),
body: { methodID: "missing", inputs: {} },
}))
.status(500, undefined, "status"),
http.protected
.get("/api/connector/oauth/{attemptID}", "v2.connector.connect.oauth.status")
.at((ctx) => ({
path: route("/api/connector/oauth/{attemptID}", { attemptID: "con_missing" }),
headers: ctx.headers(),
}))
.status(500, undefined, "status"),
http.protected
.post("/api/connector/oauth/{attemptID}/complete", "v2.connector.connect.oauth.complete")
.at((ctx) => ({
path: route("/api/connector/oauth/{attemptID}/complete", { attemptID: "con_missing" }),
headers: ctx.headers(),
body: {},
}))
.status(500, undefined, "status"),
http.protected
.delete("/api/connector/oauth/{attemptID}", "v2.connector.connect.oauth.cancel")
.at((ctx) => ({
path: route("/api/connector/oauth/{attemptID}", { attemptID: "con_missing" }),
headers: ctx.headers(),
}))
.status(204, undefined, "status"),
http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)),
http.protected.get("/api/skill", "v2.skill.list").json(200, locationData(array)),
http.protected

View file

@ -115,6 +115,30 @@ describe("PublicApi OpenAPI v2 errors", () => {
}
})
test("documents connector discovery and connection routes", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const [method, path] of [
["get", "/api/connector"],
["get", "/api/connector/{connectorID}"],
["post", "/api/connector/{connectorID}/connect/key"],
["post", "/api/connector/{connectorID}/connect/oauth"],
["get", "/api/connector/oauth/{attemptID}"],
["post", "/api/connector/oauth/{attemptID}/complete"],
["delete", "/api/connector/oauth/{attemptID}"],
] as const) {
expect(spec.paths[path]?.[method], `${method.toUpperCase()} ${path}`).toBeDefined()
}
for (const path of [
"/api/connector/{connectorID}/connect/key",
"/api/connector/{connectorID}/connect/oauth",
"/api/connector/oauth/{attemptID}/complete",
]) {
expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
}
})
test("does not rewrite /api endpoint errors to legacy error components", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const refs = v2Operations(spec)

View file

@ -265,6 +265,20 @@ import type {
V2AgentListResponses,
V2CommandListErrors,
V2CommandListResponses,
V2ConnectorConnectKeyErrors,
V2ConnectorConnectKeyResponses,
V2ConnectorConnectOauthBeginErrors,
V2ConnectorConnectOauthBeginResponses,
V2ConnectorConnectOauthCancelErrors,
V2ConnectorConnectOauthCancelResponses,
V2ConnectorConnectOauthCompleteErrors,
V2ConnectorConnectOauthCompleteResponses,
V2ConnectorConnectOauthStatusErrors,
V2ConnectorConnectOauthStatusResponses,
V2ConnectorGetErrors,
V2ConnectorGetResponses,
V2ConnectorListErrors,
V2ConnectorListResponses,
V2EventSubscribeErrors,
V2EventSubscribeResponses,
V2FsFindErrors,
@ -5567,6 +5581,297 @@ export class Provider2 extends HeyApiClient {
}
}
export class Oauth2 extends HeyApiClient {
/**
* Begin OAuth connection
*
* Start an OAuth attempt and return the authorization details.
*/
public begin<ThrowOnError extends boolean = false>(
parameters: {
connectorID: string
location?: {
directory?: string
workspace?: string
}
methodID?: string
inputs?: {
[key: string]: string
}
label?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "connectorID" },
{ in: "query", key: "location" },
{ in: "body", key: "methodID" },
{ in: "body", key: "inputs" },
{ in: "body", key: "label" },
],
},
],
)
return (options?.client ?? this.client).post<
V2ConnectorConnectOauthBeginResponses,
V2ConnectorConnectOauthBeginErrors,
ThrowOnError
>({
url: "/api/connector/{connectorID}/connect/oauth",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Cancel OAuth connection
*
* Cancel an OAuth attempt and release its resources.
*/
public cancel<ThrowOnError extends boolean = false>(
parameters: {
attemptID: string
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "attemptID" },
{ in: "query", key: "location" },
],
},
],
)
return (options?.client ?? this.client).delete<
V2ConnectorConnectOauthCancelResponses,
V2ConnectorConnectOauthCancelErrors,
ThrowOnError
>({
url: "/api/connector/oauth/{attemptID}",
...options,
...params,
})
}
/**
* Get OAuth attempt status
*
* Poll the current status of an OAuth attempt.
*/
public status<ThrowOnError extends boolean = false>(
parameters: {
attemptID: string
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "attemptID" },
{ in: "query", key: "location" },
],
},
],
)
return (options?.client ?? this.client).get<
V2ConnectorConnectOauthStatusResponses,
V2ConnectorConnectOauthStatusErrors,
ThrowOnError
>({
url: "/api/connector/oauth/{attemptID}",
...options,
...params,
})
}
/**
* Complete OAuth connection
*
* Complete a code-based OAuth attempt and store the resulting credential.
*/
public complete<ThrowOnError extends boolean = false>(
parameters: {
attemptID: string
location?: {
directory?: string
workspace?: string
}
code?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "attemptID" },
{ in: "query", key: "location" },
{ in: "body", key: "code" },
],
},
],
)
return (options?.client ?? this.client).post<
V2ConnectorConnectOauthCompleteResponses,
V2ConnectorConnectOauthCompleteErrors,
ThrowOnError
>({
url: "/api/connector/oauth/{attemptID}/complete",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Connect extends HeyApiClient {
/**
* Connect with key
*
* Run a key authentication method and store the resulting credential.
*/
public key<ThrowOnError extends boolean = false>(
parameters: {
connectorID: string
location?: {
directory?: string
workspace?: string
}
methodID?: string
key?: string
inputs?: {
[key: string]: string
}
label?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "connectorID" },
{ in: "query", key: "location" },
{ in: "body", key: "methodID" },
{ in: "body", key: "key" },
{ in: "body", key: "inputs" },
{ in: "body", key: "label" },
],
},
],
)
return (options?.client ?? this.client).post<
V2ConnectorConnectKeyResponses,
V2ConnectorConnectKeyErrors,
ThrowOnError
>({
url: "/api/connector/{connectorID}/connect/key",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
private _oauth?: Oauth2
get oauth(): Oauth2 {
return (this._oauth ??= new Oauth2({ client: this.client }))
}
}
export class Connector extends HeyApiClient {
/**
* List connectors
*
* Retrieve available connectors and their authentication methods.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<V2ConnectorListResponses, V2ConnectorListErrors, ThrowOnError>({
url: "/api/connector",
...options,
...params,
})
}
/**
* Get connector
*
* Retrieve one connector and its authentication methods.
*/
public get<ThrowOnError extends boolean = false>(
parameters: {
connectorID: string
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "connectorID" },
{ in: "query", key: "location" },
],
},
],
)
return (options?.client ?? this.client).get<V2ConnectorGetResponses, V2ConnectorGetErrors, ThrowOnError>({
url: "/api/connector/{connectorID}",
...options,
...params,
})
}
private _connect?: Connect
get connect(): Connect {
return (this._connect ??= new Connect({ client: this.client }))
}
}
export class Request extends HeyApiClient {
/**
* List pending permission requests
@ -5922,6 +6227,11 @@ export class V2 extends HeyApiClient {
return (this._provider ??= new Provider2({ client: this.client }))
}
private _connector?: Connector
get connector(): Connector {
return (this._connector ??= new Connector({ client: this.client }))
}
private _permission?: Permission3
get permission(): Permission3 {
return (this._permission ??= new Permission3({ client: this.client }))

View file

@ -6,6 +6,9 @@ export type ClientOptions = {
export type Event =
| EventModelsDevRefreshed
| EventCredentialAdded
| EventCredentialRemoved
| EventCredentialSwitched
| EventPluginAdded
| EventCatalogModelUpdated
| EventSessionCreated
@ -52,9 +55,7 @@ export type Event =
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventFileEdited
| EventAccountAdded
| EventAccountRemoved
| EventAccountSwitched
| EventConnectorUpdated
| EventPermissionV2Asked
| EventPermissionV2Replied
| EventReferenceUpdated
@ -730,6 +731,29 @@ export type GlobalEvent = {
[key: string]: unknown
}
}
| {
id: string
type: "credential.added"
properties: {
credential: CredentialInfo
}
}
| {
id: string
type: "credential.removed"
properties: {
credential: CredentialInfo
}
}
| {
id: string
type: "credential.switched"
properties: {
connectorID: string
from?: string
to?: string
}
}
| {
id: string
type: "plugin.added"
@ -1251,25 +1275,9 @@ export type GlobalEvent = {
}
| {
id: string
type: "account.added"
type: "connector.updated"
properties: {
account: AuthInfo
}
}
| {
id: string
type: "account.removed"
properties: {
account: AuthInfo
}
}
| {
id: string
type: "account.switched"
properties: {
serviceID: string
from?: string
to?: string
[key: string]: unknown
}
}
| {
@ -2838,6 +2846,34 @@ export type MoveSessionDestination = {
directory: string
}
export type CredentialOAuth = {
type: "oauth"
refresh: string
access: string
expires: number
metadata?: {
[key: string]: string
}
}
export type CredentialKey = {
type: "key"
key: string
metadata?: {
[key: string]: string
}
}
export type CredentialValue = CredentialOAuth | CredentialKey
export type CredentialInfo = {
id: string
connectorID: string
methodID: string
label: string
value: CredentialValue
}
export type ModelV2Info = {
id: string
providerID: string
@ -2988,30 +3024,6 @@ export type SessionNextRetryError = {
}
}
export type AuthOAuthCredential = {
type: "oauth"
refresh: string
access: string
expires: number
}
export type AuthApiKeyCredential = {
type: "api"
key: string
metadata?: {
[key: string]: string
}
}
export type AuthCredential = AuthOAuthCredential | AuthApiKeyCredential
export type AuthInfo = {
id: string
serviceID: string
description: string
credential: AuthCredential
}
export type PermissionV2Source = {
type: "tool"
messageID: string
@ -4062,8 +4074,8 @@ export type ProviderV2Info = {
name: string
}
| {
via: "account"
service: string
via: "credential"
credentialID: string
}
| {
via: "custom"
@ -4098,6 +4110,63 @@ export type ProviderV2Info = {
}
}
export type ConnectorWhen = {
key: string
op: "eq" | "neq"
value: string
}
export type ConnectorTextPrompt = {
type: "text"
key: string
message: string
placeholder?: string
when?: ConnectorWhen
}
export type ConnectorSelectPrompt = {
type: "select"
key: string
message: string
options: Array<{
label: string
value: string
hint?: string
}>
when?: ConnectorWhen
}
export type ConnectorOAuthMethod = {
id: string
type: "oauth"
label: string
prompts?: Array<ConnectorTextPrompt | ConnectorSelectPrompt>
}
export type ConnectorKeyMethod = {
id: string
type: "key"
label: string
prompts?: Array<ConnectorTextPrompt | ConnectorSelectPrompt>
}
export type ConnectorInfo = {
id: string
name: string
methods: Array<ConnectorOAuthMethod | ConnectorKeyMethod>
}
export type ConnectorAttempt = {
attemptID: string
url: string
instructions: string
mode: "auto" | "code"
time: {
created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
}
export type PermissionV2Request = {
id: string
sessionID: string
@ -4200,6 +4269,32 @@ export type EventModelsDevRefreshed = {
}
}
export type EventCredentialAdded = {
id: string
type: "credential.added"
properties: {
credential: CredentialInfo
}
}
export type EventCredentialRemoved = {
id: string
type: "credential.removed"
properties: {
credential: CredentialInfo
}
}
export type EventCredentialSwitched = {
id: string
type: "credential.switched"
properties: {
connectorID: string
from?: string
to?: string
}
}
export type EventPluginAdded = {
id: string
type: "plugin.added"
@ -4861,29 +4956,11 @@ export type EventFileEdited = {
}
}
export type EventAccountAdded = {
export type EventConnectorUpdated = {
id: string
type: "account.added"
type: "connector.updated"
properties: {
account: AuthInfo
}
}
export type EventAccountRemoved = {
id: string
type: "account.removed"
properties: {
account: AuthInfo
}
}
export type EventAccountSwitched = {
id: string
type: "account.switched"
properties: {
serviceID: string
from?: string
to?: string
[key: string]: unknown
}
}
@ -9982,6 +10059,320 @@ export type V2ProviderGetResponses = {
export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses]
export type V2ConnectorListData = {
body?: never
path?: never
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/connector"
}
export type V2ConnectorListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ConnectorListError = V2ConnectorListErrors[keyof V2ConnectorListErrors]
export type V2ConnectorListResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data: Array<ConnectorInfo>
}
}
export type V2ConnectorListResponse = V2ConnectorListResponses[keyof V2ConnectorListResponses]
export type V2ConnectorGetData = {
body?: never
path: {
connectorID: string
}
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/connector/{connectorID}"
}
export type V2ConnectorGetErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ConnectorGetError = V2ConnectorGetErrors[keyof V2ConnectorGetErrors]
export type V2ConnectorGetResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data: ConnectorInfo
}
}
export type V2ConnectorGetResponse = V2ConnectorGetResponses[keyof V2ConnectorGetResponses]
export type V2ConnectorConnectKeyData = {
body: {
methodID: string
key: string
inputs: {
[key: string]: string
}
label?: string
}
path: {
connectorID: string
}
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/connector/{connectorID}/connect/key"
}
export type V2ConnectorConnectKeyErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ConnectorConnectKeyError = V2ConnectorConnectKeyErrors[keyof V2ConnectorConnectKeyErrors]
export type V2ConnectorConnectKeyResponses = {
/**
* <No Content>
*/
204: void
}
export type V2ConnectorConnectKeyResponse = V2ConnectorConnectKeyResponses[keyof V2ConnectorConnectKeyResponses]
export type V2ConnectorConnectOauthBeginData = {
body: {
methodID: string
inputs: {
[key: string]: string
}
label?: string
}
path: {
connectorID: string
}
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/connector/{connectorID}/connect/oauth"
}
export type V2ConnectorConnectOauthBeginErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ConnectorConnectOauthBeginError =
V2ConnectorConnectOauthBeginErrors[keyof V2ConnectorConnectOauthBeginErrors]
export type V2ConnectorConnectOauthBeginResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data: ConnectorAttempt
}
}
export type V2ConnectorConnectOauthBeginResponse =
V2ConnectorConnectOauthBeginResponses[keyof V2ConnectorConnectOauthBeginResponses]
export type V2ConnectorConnectOauthCancelData = {
body?: never
path: {
attemptID: string
}
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/connector/oauth/{attemptID}"
}
export type V2ConnectorConnectOauthCancelErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ConnectorConnectOauthCancelError =
V2ConnectorConnectOauthCancelErrors[keyof V2ConnectorConnectOauthCancelErrors]
export type V2ConnectorConnectOauthCancelResponses = {
/**
* <No Content>
*/
204: void
}
export type V2ConnectorConnectOauthCancelResponse =
V2ConnectorConnectOauthCancelResponses[keyof V2ConnectorConnectOauthCancelResponses]
export type V2ConnectorConnectOauthStatusData = {
body?: never
path: {
attemptID: string
}
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/connector/oauth/{attemptID}"
}
export type V2ConnectorConnectOauthStatusErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ConnectorConnectOauthStatusError =
V2ConnectorConnectOauthStatusErrors[keyof V2ConnectorConnectOauthStatusErrors]
export type V2ConnectorConnectOauthStatusResponses = {
/**
* Success
*/
200: {
location: LocationInfo
data:
| {
status: "pending"
time: {
created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
}
| {
status: "complete"
time: {
created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
}
| {
status: "failed"
message: string
time: {
created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
}
| {
status: "expired"
time: {
created: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
expires: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
}
}
}
export type V2ConnectorConnectOauthStatusResponse =
V2ConnectorConnectOauthStatusResponses[keyof V2ConnectorConnectOauthStatusResponses]
export type V2ConnectorConnectOauthCompleteData = {
body: {
code?: string
}
path: {
attemptID: string
}
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/connector/oauth/{attemptID}/complete"
}
export type V2ConnectorConnectOauthCompleteErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ConnectorConnectOauthCompleteError =
V2ConnectorConnectOauthCompleteErrors[keyof V2ConnectorConnectOauthCompleteErrors]
export type V2ConnectorConnectOauthCompleteResponses = {
/**
* <No Content>
*/
204: void
}
export type V2ConnectorConnectOauthCompleteResponse =
V2ConnectorConnectOauthCompleteResponses[keyof V2ConnectorConnectOauthCompleteResponses]
export type V2PermissionRequestListData = {
body?: never
path?: never

View file

@ -15,6 +15,7 @@ import { QuestionGroup } from "./groups/question"
import { ReferenceGroup } from "./groups/reference"
import { Authorization } from "./middleware/authorization"
import { LocationGroup } from "./groups/location"
import { ConnectorGroup } from "./groups/connector"
export const Api = HttpApi.make("server")
.add(HealthGroup)
@ -24,6 +25,7 @@ export const Api = HttpApi.make("server")
.add(MessageGroup)
.add(ModelGroup)
.add(ProviderGroup)
.add(ConnectorGroup)
.add(PermissionGroup)
.add(FileSystemGroup)
.add(CommandGroup)

View file

@ -0,0 +1,133 @@
import { Connector } from "@opencode-ai/core/connector"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { InvalidRequestError } from "../errors"
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
const Inputs = Schema.Record(Schema.String, Schema.String)
export const ConnectorGroup = HttpApiGroup.make("server.connector")
.add(
HttpApiEndpoint.get("connector.list", "/api/connector", {
query: LocationQuery,
success: Location.response(Schema.Array(Connector.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.list",
summary: "List connectors",
description: "Retrieve available connectors and their authentication methods.",
}),
),
)
.add(
HttpApiEndpoint.get("connector.get", "/api/connector/:connectorID", {
params: { connectorID: Connector.ID },
query: LocationQuery,
success: Location.response(Schema.UndefinedOr(Connector.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.get",
summary: "Get connector",
description: "Retrieve one connector and its authentication methods.",
}),
),
)
.add(
HttpApiEndpoint.post("connector.connect.key", "/api/connector/:connectorID/connect/key", {
params: { connectorID: Connector.ID },
query: LocationQuery,
payload: Schema.Struct({
methodID: Connector.MethodID,
key: Schema.String,
inputs: Inputs,
label: Schema.optional(Schema.String),
}),
success: HttpApiSchema.NoContent,
error: InvalidRequestError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.connect.key",
summary: "Connect with key",
description: "Run a key authentication method and store the resulting credential.",
}),
),
)
.add(
HttpApiEndpoint.post("connector.connect.oauth.begin", "/api/connector/:connectorID/connect/oauth", {
params: { connectorID: Connector.ID },
query: LocationQuery,
payload: Schema.Struct({
methodID: Connector.MethodID,
inputs: Inputs,
label: Schema.optional(Schema.String),
}),
success: Location.response(Connector.Attempt),
error: InvalidRequestError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.connect.oauth.begin",
summary: "Begin OAuth connection",
description: "Start an OAuth attempt and return the authorization details.",
}),
),
)
.add(
HttpApiEndpoint.get("connector.connect.oauth.status", "/api/connector/oauth/:attemptID", {
params: { attemptID: Connector.AttemptID },
query: LocationQuery,
success: Location.response(Connector.AttemptStatus),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.connect.oauth.status",
summary: "Get OAuth attempt status",
description: "Poll the current status of an OAuth attempt.",
}),
),
)
.add(
HttpApiEndpoint.post("connector.connect.oauth.complete", "/api/connector/oauth/:attemptID/complete", {
params: { attemptID: Connector.AttemptID },
query: LocationQuery,
payload: Schema.Struct({ code: Schema.optional(Schema.String) }),
success: HttpApiSchema.NoContent,
error: InvalidRequestError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.connect.oauth.complete",
summary: "Complete OAuth connection",
description: "Complete a code-based OAuth attempt and store the resulting credential.",
}),
),
)
.add(
HttpApiEndpoint.delete("connector.connect.oauth.cancel", "/api/connector/oauth/:attemptID", {
params: { attemptID: Connector.AttemptID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.connect.oauth.cancel",
summary: "Cancel OAuth connection",
description: "Cancel an OAuth attempt and release its resources.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "connectors", description: "Connector discovery and authentication routes." }),
)
.middleware(LocationMiddleware)

View file

@ -19,6 +19,7 @@ import { QuestionHandler } from "./handlers/question"
import { ReferenceHandler } from "./handlers/reference"
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
import { LocationHandler } from "./handlers/location"
import { ConnectorHandler } from "./handlers/connector"
export const handlers = Layer.mergeAll(
HealthHandler,
@ -28,6 +29,7 @@ export const handlers = Layer.mergeAll(
MessageHandler,
ModelHandler,
ProviderHandler,
ConnectorHandler,
PermissionHandler,
FileSystemHandler,
CommandHandler,

View file

@ -0,0 +1,102 @@
import { Connector } from "@opencode-ai/core/connector"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { InvalidRequestError } from "../errors"
import { response } from "../groups/location"
const authorize = <A, R>(effect: Effect.Effect<A, Connector.AuthorizationError, R>) =>
effect.pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Authentication failed",
kind: "connector_authorization",
}),
),
)
export const ConnectorHandler = HttpApiBuilder.group(Api, "server.connector", (handlers) =>
Effect.gen(function* () {
return handlers
.handle(
"connector.list",
Effect.fn(function* () {
const service = yield* Connector.Service
return yield* response(service.list())
}),
)
.handle(
"connector.get",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
return yield* response(service.get(ctx.params.connectorID))
}),
)
.handle(
"connector.connect.key",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
yield* authorize(
service.connect.key({
connectorID: ctx.params.connectorID,
methodID: ctx.payload.methodID,
key: ctx.payload.key,
inputs: ctx.payload.inputs,
label: ctx.payload.label,
}),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"connector.connect.oauth.begin",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
return yield* response(
authorize(
service.connect.oauth.begin({
connectorID: ctx.params.connectorID,
methodID: ctx.payload.methodID,
inputs: ctx.payload.inputs,
label: ctx.payload.label,
}),
),
)
}),
)
.handle(
"connector.connect.oauth.status",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
return yield* response(service.connect.oauth.status(ctx.params.attemptID))
}),
)
.handle(
"connector.connect.oauth.complete",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
yield* service.connect.oauth
.complete({ attemptID: ctx.params.attemptID, code: ctx.payload.code })
.pipe(
Effect.mapError(
(error) =>
new InvalidRequestError({
message: error._tag === "Connector.CodeRequired" ? "Authorization code is required" : "Authentication failed",
kind: error._tag === "Connector.CodeRequired" ? "connector_code_required" : "connector_authorization",
}),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"connector.connect.oauth.cancel",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
yield* service.connect.oauth.cancel(ctx.params.attemptID)
return HttpApiSchema.NoContent.make()
}),
)
}),
)

View file

@ -2,6 +2,7 @@ import { useEvent } from "./event"
import type {
AgentV2Info,
CommandV2Info,
ConnectorInfo,
Event,
LocationRef,
ModelV2Info,
@ -26,6 +27,7 @@ import { createSignal, onMount } from "solid-js"
type LocationData = {
agent?: AgentV2Info[]
command?: CommandV2Info[]
connector?: ConnectorInfo[]
model?: ModelV2Info[]
provider?: ProviderV2Info[]
reference?: ReferenceInfo[]
@ -119,7 +121,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
}
event.subscribe((event) => {
event.subscribe((event, metadata) => {
switch (event.type) {
case "session.next.agent.switched":
message.update(event.properties.sessionID, (draft) => {
@ -421,6 +423,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "reference.updated":
void result.location.reference.refresh()
break
case "credential.switched": {
const location = { directory: metadata.directory, workspaceID: metadata.workspace }
void Promise.allSettled([
result.location.model.refresh(location),
result.location.provider.refresh(location),
])
break
}
case "connector.updated":
void result.location.connector.refresh({ directory: metadata.directory, workspaceID: metadata.workspace })
break
}
})
@ -503,6 +516,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("location", key, "command", result.data.data)
},
},
connector: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.connector
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.connector.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "connector", result.data.data)
},
},
model: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.model
@ -550,6 +573,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
void Promise.allSettled([
result.location.refresh(),
result.location.agent.refresh(),
result.location.connector.refresh(),
result.location.model.refresh(),
result.location.provider.refresh(),
result.location.reference.refresh(),

View file

@ -2,6 +2,7 @@ import type { Event } from "@opencode-ai/sdk/v2"
import { useSDK } from "./sdk"
type EventMetadata = {
directory: string
workspace: string | undefined
}
@ -14,7 +15,7 @@ export function useEvent() {
return
}
handler(event.payload, { workspace: event.workspace })
handler(event.payload, { directory: event.directory, workspace: event.workspace })
})
}

View file

@ -92,6 +92,63 @@ test("refreshes resources into reactive getters", async () => {
}
})
test("refreshes connectors after connector updates", async () => {
const events = createEventSource()
let requests = 0
const calls = createFetch((url) => {
if (url.pathname !== "/api/connector") return
requests++
return json({
location: { directory, project: { id: "proj_test", directory } },
data:
requests === 1
? []
: [
{
id: "openai",
name: "OpenAI",
methods: [{ id: "api-key", type: "key", label: "API Key" }],
},
],
})
})
let data!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
data = useData()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await mounted
await wait(() => data.location.connector.list() !== undefined)
expect(data.location.connector.list()).toEqual([])
emitEvent(events, { id: "evt_connector", type: "connector.updated", properties: {} })
await wait(() => data.location.connector.list()?.length === 1)
expect(data.location.connector.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
} finally {
app.renderer.destroy()
}
})
test("refreshes references after updates", async () => {
const events = createEventSource()
let requests = 0

View file

@ -60,7 +60,7 @@ export function createFetch(override?: FetchHandler) {
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
if (["/api/agent", "/api/model", "/api/provider", "/api/command", "/api/skill"].includes(url.pathname))
if (["/api/agent", "/api/model", "/api/provider", "/api/connector", "/api/command", "/api/skill"].includes(url.pathname))
return json({
location: { directory, project: { id: "proj_test", directory: worktree } },
data: [],