mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 00:28:29 +00:00
feat(core): add connector authentication (#31837)
This commit is contained in:
parent
bf05e8a122
commit
dac0dd5309
47 changed files with 5433 additions and 862 deletions
|
|
@ -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;
|
||||
2190
packages/core/migration/20260611035744_credential/snapshot.json
generated
Normal file
2190
packages/core/migration/20260611035744_credential/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
496
packages/core/src/connector.ts
Normal file
496
packages/core/src/connector.ts
Normal 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)
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
9
packages/core/src/connector/schema.ts
Normal file
9
packages/core/src/connector/schema.ts
Normal 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
|
||||
329
packages/core/src/credential.ts
Normal file
329
packages/core/src/credential.ts
Normal 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),
|
||||
),
|
||||
),
|
||||
)
|
||||
21
packages/core/src/credential/sql.ts
Normal file
21
packages/core/src/credential/sql.ts
Normal 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`),
|
||||
],
|
||||
)
|
||||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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* () {}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
251
packages/core/src/plugin/provider/openai-auth.ts
Normal file
251
packages/core/src/plugin/provider/openai-auth.ts
Normal 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>`
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
403
packages/core/test/connector.test.ts
Normal file
403
packages/core/test/connector.test.ts
Normal 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)))
|
||||
})
|
||||
})
|
||||
207
packages/core/test/credential.test.ts
Normal file
207
packages/core/test/credential.test.ts
Normal 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))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
14
packages/core/test/plugin/fixtures/models-dev.json
Normal file
14
packages/core/test/plugin/fixtures/models-dev.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"acme": {
|
||||
"id": "acme",
|
||||
"name": "Acme",
|
||||
"env": ["ACME_API_KEY"],
|
||||
"models": {}
|
||||
},
|
||||
"local": {
|
||||
"id": "local",
|
||||
"name": "Local",
|
||||
"env": [],
|
||||
"models": {}
|
||||
}
|
||||
}
|
||||
70
packages/core/test/plugin/models-dev.test.ts
Normal file
70
packages/core/test/plugin/models-dev.test.ts
Normal 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
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"), () => {}))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
133
packages/server/src/groups/connector.ts
Normal file
133
packages/server/src/groups/connector.ts
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
102
packages/server/src/handlers/connector.ts
Normal file
102
packages/server/src/handlers/connector.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue