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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,8 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Connector } from "@opencode-ai/core/connector"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
@ -46,8 +48,15 @@ export const catalogLayer = Layer.succeed(
}),
)
const connectors = Connector.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(Layer.mock(Credential.Service)({ create: () => Effect.die("unexpected credential creation") })),
)
export const it = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(connectors),
Layer.provideMerge(Layer.mock(Credential.Service)({ activeAll: () => Effect.succeed(new Map()) })),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(npmLayer),

View file

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

View file

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