feat(core): add connector authentication (#31837)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"acme": {
|
||||
"id": "acme",
|
||||
"name": "Acme",
|
||||
"env": ["ACME_API_KEY"],
|
||||
"models": {}
|
||||
},
|
||||
"local": {
|
||||
"id": "local",
|
||||
"name": "Local",
|
||||
"env": [],
|
||||
"models": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user