feat(core): port provider integrations to v2

This commit is contained in:
Aiden Cline
2026-07-12 18:40:56 +00:00
parent 430750e2f8
commit df04a0133b
31 changed files with 2000 additions and 152 deletions
+1
View File
@@ -406,6 +406,7 @@ export type Endpoint10_2Input = {
readonly integrationID: Endpoint10_2Request["params"]["integrationID"]
readonly location?: Endpoint10_2Request["query"]["location"]
readonly key: Endpoint10_2Request["payload"]["key"]
readonly inputs?: Endpoint10_2Request["payload"]["inputs"]
readonly label?: Endpoint10_2Request["payload"]["label"]
}
export type Endpoint10_2Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
@@ -504,13 +504,14 @@ type Endpoint10_2Input = {
readonly integrationID: Endpoint10_2Request["params"]["integrationID"]
readonly location?: Endpoint10_2Request["query"]["location"]
readonly key: Endpoint10_2Request["payload"]["key"]
readonly inputs?: Endpoint10_2Request["payload"]["inputs"]
readonly label?: Endpoint10_2Request["payload"]["label"]
}
const Endpoint10_2 = (raw: RawClient["server.integration"]) => (input: Endpoint10_2Input) =>
raw["integration.connect.key"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { key: input["key"], label: input["label"] },
payload: { key: input["key"], inputs: input["inputs"], label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
@@ -880,7 +880,7 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
query: { location: input["location"] },
body: { key: input["key"], label: input["label"] },
body: { key: input["key"], inputs: input["inputs"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+21 -4
View File
@@ -183,8 +183,6 @@ export type ProviderV2Info = {
export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
export type IntegrationKeyMethod = { type: "key"; label?: string }
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string }
@@ -1868,6 +1866,12 @@ export type IntegrationOAuthMethod = {
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
}
export type IntegrationKeyMethod = {
type: "key"
label?: string
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
}
export type FormField =
| FormStringField
| FormNumberField
@@ -3276,8 +3280,21 @@ export type IntegrationConnectKeyInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
readonly key: {
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["key"]
readonly inputs?: {
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["inputs"]
readonly label?: {
readonly key: string
readonly inputs?: { readonly [x: string]: string } | undefined
readonly label?: string | undefined
}["label"]
}
export type IntegrationConnectKeyOutput = void
+21 -10
View File
@@ -85,6 +85,7 @@ export interface OAuthImplementation {
export interface KeyImplementation {
readonly integrationID: ID
readonly method: KeyMethod
readonly authorize?: (key: string, inputs: Inputs) => Effect.Effect<Credential.Key, unknown>
}
export interface EnvImplementation {
@@ -119,6 +120,7 @@ type Entry = {
ref: Types.DeepMutable<Ref>
methods: Types.DeepMutable<Method>[]
implementations: Map<MethodID, Types.DeepMutable<OAuthImplementation>>
key?: Types.DeepMutable<KeyImplementation>
}
type Data = {
@@ -156,6 +158,8 @@ export interface Interface extends State.Transformable<Draft> {
readonly integrationID: ID
/** 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>
@@ -237,6 +241,7 @@ const layer = Layer.effect(
ref: { id, name: id },
methods: [],
implementations: new Map(),
key: undefined,
}
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
update(current.ref)
@@ -253,6 +258,7 @@ const layer = Layer.effect(
},
methods: [],
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
key: undefined,
}
if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current)
@@ -270,6 +276,9 @@ const layer = Layer.effect(
implementation as Types.DeepMutable<OAuthImplementation>,
)
}
if (implementation.method.type === "key") {
current.key = implementation as Types.DeepMutable<KeyImplementation>
}
},
remove: (integrationID, method) => {
const current = draft.integrations.get(integrationID)
@@ -281,6 +290,7 @@ const layer = Layer.effect(
})
if (index !== -1) current.methods.splice(index, 1)
if (method.type === "oauth") current.implementations.delete(method.id)
if (method.type === "key") current.key = undefined
},
},
}),
@@ -365,10 +375,10 @@ const layer = Layer.effect(
? { status: "complete", time: attempt.time, removeAt: settledAt + terminalRetention }
: {
status: "failed",
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
message: message(persistence.cause),
time: attempt.time,
removeAt: settledAt + terminalRetention,
}
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
@@ -438,15 +448,16 @@ const layer = Layer.effect(
return value
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state
.get()
.integrations.get(input.integrationID)
?.methods.some((method) => method.type === "key")
if (!method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
const entry = state.get().integrations.get(input.integrationID)
const method = entry?.methods.some((method) => method.type === "key")
if (!entry || !method) return yield* Effect.die(new Error(`Key method not found: ${input.integrationID}`))
const value = entry.key?.authorize
? yield* authorize(entry.key.authorize(input.key, input.inputs ?? {}))
: Credential.Key.make({ type: "key", key: input.key })
yield* credentials.create({
integrationID: input.integrationID,
label: input.label,
value: Credential.Key.make({ type: "key", key: input.key }),
value,
})
yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* events.publish(Event.Updated, {})
+26 -10
View File
@@ -1,6 +1,11 @@
export * as PluginHost from "./host"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import type {
IntegrationInputs,
IntegrationKeyMethodRegistration,
IntegrationOAuthMethodRegistration,
} from "@opencode-ai/plugin/v2/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent"
@@ -171,6 +176,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
key: input.key,
inputs: input.inputs,
label: input.label,
}),
oauth: (input) =>
@@ -207,14 +213,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
method: {
list: (id) => mutable(draft.method.list(Integration.ID.make(id))),
update: (input) => {
if ("authorize" in input) {
const methodID = Integration.MethodID.make(input.method.id)
const refresh = input.refresh
if (input.method.type === "oauth") {
const oauth = input as IntegrationOAuthMethodRegistration
const methodID = Integration.MethodID.make(oauth.method.id)
const refresh = oauth.refresh
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: methodID },
authorize: (inputs) =>
input.authorize(inputs).pipe(
integrationID: Integration.ID.make(oauth.integrationID),
method: { ...oauth.method, id: methodID },
authorize: (inputs: IntegrationInputs) =>
oauth.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
@@ -256,7 +263,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
),
}
: {}),
...(input.label ? { label: input.label } : {}),
...(oauth.label ? { label: oauth.label } : {}),
})
return
}
@@ -267,9 +274,18 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
return
}
const registration = input as IntegrationKeyMethodRegistration
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { type: "key", label: input.method.label },
integrationID: Integration.ID.make(registration.integrationID),
method: { type: "key", label: registration.method.label, prompts: registration.method.prompts },
...(registration.authorize
? {
authorize: (key, inputs) =>
registration.authorize!(key, inputs).pipe(
Effect.map((credential) => Schema.decodeUnknownSync(Credential.Key)(credential)),
),
}
: {}),
})
},
remove: (id, method) =>
+4
View File
@@ -7,6 +7,7 @@ import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway"
import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai"
import { CoherePlugin } from "./provider/cohere"
import { DeepInfraPlugin } from "./provider/deepinfra"
import { DigitalOceanPlugin } from "./provider/digitalocean"
import { DynamicProviderPlugin } from "./provider/dynamic"
import { GatewayPlugin } from "./provider/gateway"
import { GithubCopilotPlugin } from "./provider/github-copilot"
@@ -24,6 +25,7 @@ import { OpenAICompatiblePlugin } from "./provider/openai-compatible"
import { OpencodePlugin } from "./provider/opencode"
import { OpenRouterPlugin } from "./provider/openrouter"
import { PerplexityPlugin } from "./provider/perplexity"
import { PoePlugin } from "./provider/poe"
import { SapAICorePlugin } from "./provider/sap-ai-core"
import { TogetherAIPlugin } from "./provider/togetherai"
import { VercelPlugin } from "./provider/vercel"
@@ -43,6 +45,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
CloudflareWorkersAIPlugin,
CoherePlugin,
DeepInfraPlugin,
DigitalOceanPlugin,
GatewayPlugin,
GithubCopilotPlugin,
GitLabPlugin,
@@ -60,6 +63,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
OpenAIPlugin,
OpenRouterPlugin,
PerplexityPlugin,
PoePlugin,
SapAICorePlugin,
TogetherAIPlugin,
VercelPlugin,
@@ -1,5 +1,7 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ProviderV2 } from "../../provider"
function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
@@ -13,6 +15,34 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
export const AzurePlugin = define({
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
const integration = yield* Integration.Service
yield* integration.transform((draft) => {
draft.method.update({
integrationID: Integration.ID.make("azure"),
method: {
type: "key",
label: "API key",
prompts: process.env.AZURE_RESOURCE_NAME
? []
: [
{
type: "text",
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
},
],
},
authorize: (key, inputs) =>
Effect.succeed(
Credential.Key.make({
type: "key",
key,
...(inputs.resourceName ? { metadata: { resourceName: inputs.resourceName } } : {}),
}),
),
})
})
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (!ProviderV2.isAISDK(item.provider.package)) continue
@@ -2,10 +2,54 @@ import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect, Option, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
const integrationID = Integration.ID.make("cloudflare-ai-gateway")
export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) {
const integration = yield* Integration.Service
yield* integration.transform((draft) => {
draft.method.update({
integrationID,
method: {
type: "key",
label: "Gateway API token",
prompts: [
...(process.env.CLOUDFLARE_ACCOUNT_ID
? []
: [
{
type: "text" as const,
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
]),
...(process.env.CLOUDFLARE_GATEWAY_ID
? []
: [
{
type: "text" as const,
key: "gatewayId",
message: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
},
]),
],
},
authorize: (key, inputs) =>
Effect.succeed(
Credential.Key.make({
type: "key",
key,
...(Object.keys(inputs).length ? { metadata: inputs } : {}),
}),
),
})
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
@@ -2,13 +2,44 @@ import os from "os"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ProviderV2 } from "../../provider"
const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
const integrationID = Integration.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({
id: "opencode.provider.cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) {
const integration = yield* Integration.Service
yield* integration.transform((draft) => {
draft.method.update({
integrationID,
method: {
type: "key",
label: "API key",
prompts: process.env.CLOUDFLARE_ACCOUNT_ID
? []
: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
],
},
authorize: (key, inputs) =>
Effect.succeed(
Credential.Key.make({
type: "key",
key,
...(inputs.accountId ? { metadata: { accountId: inputs.accountId } } : {}),
}),
),
})
})
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
@@ -0,0 +1,199 @@
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import { Credential } from "../../credential"
import { EventV2 } from "../../event"
import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider"
import type { PluginInternal } from "../internal"
const clientID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
const authorizeURL = "https://cloud.digitalocean.com/v1/oauth/authorize"
const genaiURL = "https://api.digitalocean.com/v2/gen-ai"
const inferenceURL = "https://inference.do-ai.run/v1"
const callbackPort = 1456
const callbackPath = "/auth/callback"
const tokenPath = "/auth/token"
const scopes = "genai:read inference:query"
const refreshInterval = 5 * 60 * 1000
const integrationID = Integration.ID.make("digitalocean")
const methodID = Integration.MethodID.make("implicit")
const Token = Schema.Struct({
access_token: Schema.String,
expires_in: Schema.NumberFromString,
state: Schema.String,
})
const Router = Schema.Struct({
name: Schema.String,
uuid: Schema.optional(Schema.String),
description: Schema.optional(Schema.String),
})
type Router = typeof Router.Type
const RouterResponse = Schema.Struct({ model_routers: Schema.optional(Schema.Array(Router)) })
const oauth = {
integrationID,
method: {
id: methodID,
type: "oauth",
label: "Login with DigitalOcean",
},
authorize: () =>
Effect.gen(function* () {
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")
const token = yield* Deferred.make<typeof Token.Type, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (request.method === "GET" && url.pathname === callbackPath) {
response
.writeHead(200, { "Content-Type": "text/html" })
.end(OauthCallbackPage.bootstrap({ tokenPath, provider: "DigitalOcean" }))
return
}
if (request.method !== "POST" || url.pathname !== tokenPath) {
response.writeHead(404).end("Not found")
return
}
const chunks: Buffer[] = []
request.on("data", (chunk: Buffer) => chunks.push(chunk))
request.on("end", () => {
const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(Token))(Buffer.concat(chunks).toString())
if (Option.isNone(decoded) || decoded.value.state !== state) {
const error = Option.isNone(decoded) ? "Invalid OAuth callback" : "Invalid OAuth state"
Effect.runFork(Deferred.fail(token, new Error(error)))
response.writeHead(400, { "Content-Type": "application/json" }).end(JSON.stringify({ error }))
return
}
Effect.runFork(Deferred.succeed(token, decoded.value))
response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ ok: true }))
})
})
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()))
const redirect = `http://localhost:${callbackPort}${callbackPath}`
return {
mode: "auto" as const,
url: `${authorizeURL}?${new URLSearchParams({
response_type: "token",
client_id: clientID,
redirect_uri: redirect,
scope: scopes,
state,
})}`,
instructions:
"Sign in to DigitalOcean in your browser. OpenCode will use the resulting token for inference and load your Inference Routers.",
callback: Deferred.await(token).pipe(
Effect.flatMap((value) =>
listRouters(value.access_token).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to sync DigitalOcean inference routers", { cause }).pipe(Effect.as([])),
),
Effect.map((routers) =>
Credential.OAuth.make({
type: "oauth",
methodID,
refresh: value.access_token,
access: value.access_token,
expires: Date.now() + value.expires_in * 1000,
metadata: { routers, routersFetchedAt: Date.now() },
}),
),
),
),
),
}
}),
} satisfies IntegrationOAuthMethodRegistration
export const DigitalOceanPlugin = define({
id: "opencode.provider.digitalocean",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const loading = Semaphore.makeUnsafe(1)
const loaded: { routers: readonly Router[] } = { routers: [] }
const load = Effect.fn("DigitalOceanPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(integrationID)
const saved = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
if (saved?.type !== "oauth") {
loaded.routers = []
return
}
const cached = Schema.decodeUnknownOption(Schema.Array(Router))(saved.metadata?.routers)
loaded.routers = cached._tag === "Some" ? cached.value : []
const fetchedAt = saved.metadata?.routersFetchedAt
if (typeof fetchedAt === "number" && Date.now() - fetchedAt <= refreshInterval) return
if (saved.expires <= Date.now()) return
const routers = yield* listRouters(saved.access).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to refresh DigitalOcean inference routers", { cause }).pipe(Effect.as(undefined)),
),
)
if (!routers) return
loaded.routers = routers
})
yield* ctx.integration.transform((draft) => {
draft.update(integrationID, (integration) => {
integration.name = "DigitalOcean"
})
draft.method.update(oauth)
draft.method.update({
integrationID,
method: { type: "key", label: "Paste Model Access Key" },
})
})
yield* ctx.catalog.transform((catalog) => {
if (!catalog.provider.get(ProviderV2.ID.make(integrationID))) return
for (const router of loaded.routers) {
const id = ModelV2.ID.make(`router:${router.name}`)
catalog.model.update(ProviderV2.ID.make(integrationID), id, (model) => {
model.modelID = id
model.name = router.name
model.family = ModelV2.Family.make("digitalocean-inference-routers")
model.package = ProviderV2.aisdk("@ai-sdk/openai-compatible")
model.settings = ProviderV2.mergeOverlay(model.settings, { baseURL: inferenceURL })
model.capabilities = { tools: true, input: ["text"], output: ["text"] }
model.limit = { context: 128_000, output: 8_192 }
})
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === integrationID),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
}),
} satisfies PluginInternal.InternalPlugin)
function listRouters(access: string) {
return Effect.tryPromise({
try: async (signal) => {
const response = await fetch(`${genaiURL}/models/routers`, {
headers: {
Authorization: `Bearer ${access}`,
Accept: "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
},
signal,
})
if (!response.ok) throw new Error(`DigitalOcean router request failed: ${response.status}`)
const body = Schema.decodeUnknownSync(RouterResponse)(await response.json())
return body.model_routers ?? []
},
catch: (cause) => cause,
})
}
+218 -1
View File
@@ -1,12 +1,153 @@
import { createServer } from "node:http"
import os from "os"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect"
import { Deferred, Effect, Schema } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider"
const defaultInstanceUrl = "https://gitlab.com"
const bundledClientID = "1d89f9fdb23ee96d4e603201f6861dab6e143c5c3c00469a018a2d94bdc03d4e"
const callbackHost = "127.0.0.1"
const callbackPort = 8080
const callbackPath = "/callback"
const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}`
const methodID = Integration.MethodID.make("oauth")
const Token = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
type Token = typeof Token.Type
const oauth = {
integrationID: Integration.ID.make("gitlab"),
method: {
id: methodID,
type: "oauth",
label: "GitLab OAuth",
prompts: [
{
type: "text",
key: "instanceUrl",
message: "GitLab instance URL",
placeholder: defaultInstanceUrl,
},
],
},
authorize: (inputs) =>
Effect.gen(function* () {
const instanceUrl = normalizeInstanceUrl(inputs.instanceUrl)
const pkce = yield* Effect.promise(generatePKCE)
const state = randomString(32)
const code = yield* Deferred.make<string, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", redirectURI)
if (url.pathname !== callbackPath) {
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(OauthCallbackPage.error(error, { provider: "GitLab" }))
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(OauthCallbackPage.error(message, { provider: "GitLab" }))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "GitLab" }))
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, callbackHost, () => resume(Effect.void))
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
return {
mode: "auto" as const,
url: `${instanceUrl}/oauth/authorize?${new URLSearchParams({
client_id: clientID(),
redirect_uri: redirectURI,
response_type: "code",
state,
scope: "api",
code_challenge: pkce.challenge,
code_challenge_method: "S256",
})}`,
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
token(instanceUrl, {
grant_type: "authorization_code",
code: value,
redirect_uri: redirectURI,
client_id: clientID(),
code_verifier: pkce.verifier,
}),
),
Effect.flatMap((value) => credential(value, instanceUrl)),
),
}
}),
refresh: (value) => {
const instanceUrl = normalizeMetadataInstanceUrl(value.metadata?.instanceUrl)
return token(instanceUrl, {
grant_type: "refresh_token",
refresh_token: value.refresh,
client_id: clientID(),
}).pipe(Effect.flatMap((next) => credential(next, instanceUrl, next.refresh_token ?? value.refresh)))
},
label: (value) => (typeof value.metadata?.instanceUrl === "string" ? value.metadata.instanceUrl : undefined),
} satisfies IntegrationOAuthMethodRegistration
export const GitLabPlugin = define({
id: "opencode.provider.gitlab",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.method.update(oauth)
draft.method.update({
integrationID: Integration.ID.make("gitlab"),
method: {
type: "key",
label: "GitLab Personal Access Token",
prompts: [
{
type: "text",
key: "instanceUrl",
message: "GitLab instance URL",
placeholder: defaultInstanceUrl,
},
],
},
authorize: (key, inputs) =>
Effect.gen(function* () {
const instanceUrl = normalizeInstanceUrl(inputs.instanceUrl)
const response = yield* send(`${instanceUrl}/api/v4/user`, {
headers: { Authorization: `Bearer ${key}` },
})
if (!response.ok)
return yield* Effect.fail(new Error(`GitLab token validation failed (${response.status})`))
return Credential.Key.make({ type: "key", key, metadata: { instanceUrl } })
}),
})
draft.method.update({
integrationID: Integration.ID.make("gitlab"),
method: { type: "env", names: ["GITLAB_TOKEN"] },
})
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
@@ -63,3 +204,79 @@ export const GitLabPlugin = define({
)
}),
})
function clientID() {
return process.env.GITLAB_OAUTH_CLIENT_ID ?? bundledClientID
}
function normalizeInstanceUrl(value?: string) {
const input = value?.trim() || process.env.GITLAB_INSTANCE_URL || defaultInstanceUrl
return normalizeURL(input)
}
function normalizeMetadataInstanceUrl(value: unknown) {
if (typeof value !== "string" || !value) throw new Error("GitLab OAuth credential is missing instanceUrl metadata")
return normalizeURL(value)
}
function normalizeURL(value: string) {
const url = new URL(value)
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("GitLab instance URL must use http or https")
}
return `${url.protocol}//${url.host}`
}
function token(instanceUrl: string, body: Record<string, string>) {
return send(`${instanceUrl}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: new URLSearchParams(body).toString(),
}).pipe(
Effect.flatMap((response) => {
if (response.ok) {
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(Token)))
}
return Effect.promise(() => response.text()).pipe(
Effect.flatMap((detail) =>
Effect.fail(new Error(`GitLab token request failed (${response.status})${detail ? `: ${detail}` : ""}`)),
),
)
}),
)
}
function send(url: string, init: RequestInit) {
return Effect.tryPromise({
try: (signal) => fetch(url, { ...init, signal }),
catch: (cause) => cause,
})
}
function credential(tokens: Token, instanceUrl: string, currentRefresh?: string) {
const refresh = tokens.refresh_token ?? currentRefresh
if (!refresh) return Effect.fail(new Error("GitLab token response is missing refresh_token"))
return Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
access: tokens.access_token,
refresh,
expires: Date.now() + (tokens.expires_in ?? 7200) * 1000,
metadata: { instanceUrl },
}),
)
}
async function generatePKCE() {
const verifier = randomString(64)
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
"base64url",
)
return { verifier, challenge }
}
function randomString(length: number) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
}
+141
View File
@@ -0,0 +1,141 @@
import { createServer } from "node:http"
import type { AddressInfo } from "node:net"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Schema } from "effect"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
const clientID = "client_728290227fc048cc9262091a1ea197ea"
const authorizationEndpoint = "https://poe.com/oauth/authorize"
const tokenEndpoint = "https://api.poe.com/token"
const callbackPath = "/callback"
const integrationID = Integration.ID.make("poe")
const methodID = Integration.MethodID.make("browser")
const Token = Schema.Struct({
api_key: Schema.String,
api_key_expires_in: Schema.optional(Schema.NullOr(Schema.Number)),
})
const oauth = {
integrationID,
method: {
id: methodID,
type: "oauth",
label: "Login with Poe (browser)",
},
authorize: () =>
Effect.gen(function* () {
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const challenge = Buffer.from(
yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
).toString("base64url")
const code = yield* Deferred.make<string, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1")
if (url.pathname !== callbackPath) {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error")
if (error) {
const description = url.searchParams.get("error_description") ?? error
Effect.runFork(Deferred.fail(code, new Error(`OAuth authorization failed: ${error} - ${description}`)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(description, { provider: "Poe" }))
return
}
const value = url.searchParams.get("code")
if (!value) {
Effect.runFork(Deferred.fail(code, new Error("OAuth callback missing authorization code")))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error("Missing authorization code", { provider: "Poe" }))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "Poe" }))
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(0, "127.0.0.1", () => resume(Effect.void))
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
server.closeAllConnections()
server.close()
}),
)
const address = server.address() as AddressInfo
const redirectURI = `http://127.0.0.1:${address.port}${callbackPath}`
return {
mode: "auto" as const,
url: `${authorizationEndpoint}?${new URLSearchParams({
response_type: "code",
client_id: clientID,
scope: "apikey:create",
code_challenge: challenge,
code_challenge_method: "S256",
redirect_uri: redirectURI,
})}`,
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
Effect.tryPromise({
try: (signal) =>
fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: value,
code_verifier: verifier,
client_id: clientID,
redirect_uri: redirectURI,
}).toString(),
signal,
}),
catch: (cause) => cause,
}),
),
Effect.flatMap((response) => {
if (response.ok)
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(Token)))
return Effect.promise(() => response.text()).pipe(
Effect.flatMap((detail) =>
Effect.fail(new Error(`Poe token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)),
),
)
}),
Effect.map((token) =>
Credential.OAuth.make({
type: "oauth",
methodID,
access: token.api_key,
refresh: token.api_key,
expires:
token.api_key_expires_in == null
? Number.MAX_SAFE_INTEGER
: Date.now() + token.api_key_expires_in * 1000,
}),
),
),
}
}),
} satisfies IntegrationOAuthMethodRegistration
export const PoePlugin = define({
id: "opencode.provider.poe",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.update(integrationID, (integration) => {
integration.name = "Poe"
})
draft.method.update(oauth)
draft.method.update({ integrationID, method: { type: "key", label: "Manually enter API Key" } })
})
}),
})
@@ -1,12 +1,46 @@
import { Effect } from "effect"
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Schema } from "effect"
import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
const integrationID = Integration.ID.make("snowflake-cortex")
const browserMethodID = Integration.MethodID.make("snowflake-browser")
const clientID = "LOCAL_APPLICATION"
const callbackHost = "127.0.0.1"
type TokenResponse = {
access_token: string
refresh_token?: string
expires_in?: number
}
const TokenResponse = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
export function oauthScope(role: string | undefined) {
if (!role) return "refresh_token"
return /^[-_A-Za-z0-9]+$/.test(role)
? `refresh_token session:role:${role}`
: `refresh_token session:role-encoded:${encodeURIComponent(role)}`
}
// Exported for testing: intercepts Cortex-specific request/response quirks.
export function cortexFetch(upstream: FetchLike = fetch) {
return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
const headers = new Headers(url instanceof Request ? url.headers : undefined)
if (init?.headers) new Headers(init.headers).forEach((value, key) => headers.set(key, value))
headers.set("User-Agent", `opencode/${InstallationVersion}`)
init = { ...init, headers }
if (init?.body && typeof init.body === "string") {
try {
const body = JSON.parse(init.body)
@@ -67,10 +101,24 @@ export function cortexFetch(upstream: FetchLike = fetch) {
export const SnowflakeCortexPlugin = define({
id: "opencode.provider.snowflake-cortex",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.method.update(browser)
draft.method.update({
integrationID: "snowflake-cortex",
method: { type: "key", label: "Paste PAT or bearer token manually" },
})
draft.method.update({
integrationID: "snowflake-cortex",
method: { type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] },
})
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
const account = normalizeAccount(
process.env.SNOWFLAKE_ACCOUNT ?? (typeof evt.options.account === "string" ? evt.options.account : ""),
)
const token =
process.env.SNOWFLAKE_CORTEX_TOKEN ??
process.env.SNOWFLAKE_CORTEX_PAT ??
@@ -78,6 +126,7 @@ export const SnowflakeCortexPlugin = define({
(typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined)
const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined
if (evt.options.includeUsage !== false) evt.options.includeUsage = true
if (account) evt.options.baseURL = `https://${account}.snowflakecomputing.com/api/v2/cortex/v1`
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible({
...evt.options,
@@ -88,3 +137,180 @@ export const SnowflakeCortexPlugin = define({
)
}),
})
const accountPrompt = {
type: "text" as const,
key: "account",
message: "Snowflake Account Identifier",
placeholder: "myorg-myaccount",
}
const browser = {
integrationID,
method: {
id: browserMethodID,
type: "oauth",
label: "Login with Snowflake (External Browser)",
prompts: [
accountPrompt,
{
type: "text",
key: "role",
message: "Snowflake Role (optional)",
placeholder: "PUBLIC",
},
],
},
authorize: (inputs) =>
Effect.gen(function* () {
const account = normalizeAccount(inputs.account ?? "")
if (!account) return yield* Effect.fail(new Error("Snowflake account is required"))
const pkce = yield* Effect.promise(generatePKCE)
const state = randomString(64)
const code = yield* Deferred.make<string, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://${callbackHost}`)
if (url.pathname !== "/") {
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(OauthCallbackPage.error(error, { provider: "Snowflake" }))
return
}
if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid state - potential CSRF attack" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
return
}
Effect.runFork(Deferred.succeed(code, value))
response
.writeHead(200, { "Content-Type": "text/html" })
.end(OauthCallbackPage.success({ provider: "Snowflake" }))
})
const port = yield* Effect.callback<number, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(0, callbackHost, () => {
const address = server.address()
if (!address || typeof address === "string") {
resume(Effect.fail(new Error("Unable to resolve Snowflake OAuth callback port")))
return
}
resume(Effect.succeed(address.port))
})
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
const redirect = `http://${callbackHost}:${port}/`
const role = inputs.role?.trim() || undefined
const url = `https://${account}.snowflakecomputing.com/oauth/authorize?${new URLSearchParams({
client_id: clientID,
response_type: "code",
redirect_uri: redirect,
scope: oauthScope(role),
state,
code_challenge: pkce.challenge,
code_challenge_method: "S256",
})}`
return {
mode: "auto" as const,
url,
instructions:
"Complete Snowflake sign-in in your browser. OpenCode will capture the OAuth callback and store the bearer token automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
token(account, {
grant_type: "authorization_code",
code: value,
redirect_uri: redirect,
client_id: clientID,
code_verifier: pkce.verifier,
}),
),
Effect.flatMap((value) =>
value.refresh_token
? Effect.succeed(credential(value, account, value.refresh_token))
: Effect.fail(
new Error(
"Snowflake token response did not include refresh_token. Ensure integration issues refresh tokens and scope includes refresh_token.",
),
),
),
),
}
}),
refresh: (value) => {
const account = typeof value.metadata?.account === "string" ? normalizeAccount(value.metadata.account) : ""
if (!account) return Effect.fail(new Error("Snowflake OAuth credential is missing account metadata"))
return token(account, {
grant_type: "refresh_token",
refresh_token: value.refresh,
client_id: clientID,
}).pipe(Effect.map((next) => credential(next, account, next.refresh_token ?? value.refresh)))
},
label: (value) => (typeof value.metadata?.account === "string" ? value.metadata.account : undefined),
} satisfies IntegrationOAuthMethodRegistration
function token(account: string, body: Record<string, string>) {
return Effect.tryPromise({
try: async (signal) => {
const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${Buffer.from(`${clientID}:${clientID}`).toString("base64")}`,
"User-Agent": `opencode/${InstallationVersion}`,
},
body: new URLSearchParams(body).toString(),
signal,
})
if (!response.ok) {
const detail = await response.text().catch(() => "")
throw new Error(`Snowflake token request failed (${response.status})${detail ? `: ${detail}` : ""}`)
}
return Schema.decodeUnknownSync(TokenResponse)(await response.json())
},
catch: (cause) => cause,
})
}
function credential(tokens: TokenResponse, account: string, refresh: string) {
return Credential.OAuth.make({
type: "oauth",
methodID: browserMethodID,
access: tokens.access_token,
refresh,
expires: Date.now() + (tokens.expires_in ?? 600) * 1000,
metadata: { account },
})
}
function normalizeAccount(input: string) {
return input
.trim()
.replace(/^https?:\/\//, "")
.replace(/\.snowflakecomputing\.com\/?$/, "")
.replace(/\/+$/, "")
}
async function generatePKCE() {
const verifier = randomString(64)
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
"base64url",
)
return { verifier, challenge }
}
function randomString(length: number) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
}
+301 -1
View File
@@ -1,10 +1,146 @@
import { Effect } from "effect"
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Clock, Deferred, Effect, Option, Schema } from "effect"
import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider"
const clientID = "b1a00492-073a-47ea-816f-4c329264a828"
const issuer = "https://auth.x.ai/oauth2"
const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"
const scope = "openid profile email offline_access grok-cli:access api:access"
const callbackHost = "127.0.0.1"
const callbackPort = 56121
const callbackPath = "/callback"
const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}`
const pollingSafetyMargin = 3000
const browserMethodID = Integration.MethodID.make("browser")
const deviceMethodID = Integration.MethodID.make("device")
type Pkce = {
verifier: string
challenge: string
}
const Token = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
})
type Token = typeof Token.Type
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
verification_uri: Schema.String,
verification_uri_complete: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
interval: Schema.optional(Schema.Number),
})
const DeviceError = Schema.Struct({
error: Schema.optional(Schema.String),
error_description: Schema.optional(Schema.String),
})
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
const browser = {
integrationID: Integration.ID.make("xai"),
method: {
id: browserMethodID,
type: "oauth",
label: "xAI Grok OAuth (SuperGrok Subscription)",
},
authorize: () =>
Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE)
const state = randomString(32)
const code = yield* Deferred.make<string, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", redirectURI)
if (url.pathname !== callbackPath) {
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(OauthCallbackPage.error(error, { provider: "xAI" }))
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(OauthCallbackPage.error(message, { provider: "xAI" }))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "xAI" }))
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, callbackHost, () => resume(Effect.void))
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
return {
mode: "auto" as const,
url: authorizeURL(pkce, state, randomString(32)),
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) => exchange(value, pkce)),
Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
),
}
}),
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })),
} satisfies IntegrationOAuthMethodRegistration
const device = {
integrationID: Integration.ID.make("xai"),
method: {
id: deviceMethodID,
type: "oauth",
label: "xAI Grok OAuth (Headless / Remote / VPS)",
},
authorize: () =>
request(
`${issuer}/device/code`,
{
method: "POST",
headers: headers(),
body: new URLSearchParams({ client_id: clientID, scope }).toString(),
},
Device,
).pipe(
Effect.map((value) => ({
mode: "auto" as const,
url: value.verification_uri_complete ?? value.verification_uri,
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
})),
),
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })),
} satisfies IntegrationOAuthMethodRegistration
export const XAIPlugin = define({
id: "opencode.provider.xai",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
draft.update("xai", (integration) => {
integration.name = "xAI"
})
draft.method.update(browser)
draft.method.update(device)
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
@@ -22,3 +158,167 @@ export const XAIPlugin = define({
)
}),
})
function exchange(code: string, pkce: Pkce) {
return request(
`${issuer}/token`,
{
method: "POST",
headers: headers(),
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectURI,
client_id: clientID,
code_verifier: pkce.verifier,
}).toString(),
},
Token,
)
}
function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
return request(
`${issuer}/token`,
{
method: "POST",
headers: headers(),
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: value.refresh,
client_id: clientID,
}).toString(),
},
Token,
).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata)))
}
function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
return Effect.gen(function* () {
const started = yield* Clock.currentTimeMillis
const expires = started + positiveSeconds(device.expires_in, 300) * 1000
const loop = (interval: number): Effect.Effect<Token, unknown> =>
Effect.gen(function* () {
if ((yield* Clock.currentTimeMillis) >= expires) {
return yield* Effect.fail(new Error("xAI device authorization timed out"))
}
const response = yield* send(`${issuer}/token`, {
method: "POST",
headers: headers(),
body: new URLSearchParams({
grant_type: deviceGrant,
client_id: clientID,
device_code: device.device_code,
}).toString(),
})
if (response.ok) return yield* decode(response, Token)
const error = yield* Effect.promise(() => response.text()).pipe(
Effect.map((body) => Option.getOrUndefined(decodeDeviceError(body))),
Effect.catch(() => Effect.succeed(undefined)),
)
if (error?.error === "authorization_pending") {
return yield* Effect.sleep(interval + pollingSafetyMargin).pipe(Effect.andThen(loop(interval)))
}
if (error?.error === "slow_down") {
const next = interval + 5000
return yield* Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(loop(next)))
}
if (error?.error === "access_denied" || error?.error === "authorization_denied") {
return yield* Effect.fail(new Error("xAI device authorization was denied"))
}
if (error?.error === "expired_token") {
return yield* Effect.fail(new Error("xAI device code expired - please re-run login"))
}
const detail = error?.error_description ?? error?.error
return yield* Effect.fail(
new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`),
)
})
return yield* loop(Math.max(positiveSeconds(device.interval, 5) * 1000, 1000))
})
}
function request<S extends Schema.Decoder<unknown>>(url: string, init: RequestInit, schema: S) {
return send(url, init).pipe(
Effect.flatMap((response) => {
if (response.ok) return decode(response, schema)
return Effect.promise(() => response.text()).pipe(
Effect.flatMap((detail) =>
Effect.fail(new Error(`xAI request failed (${response.status})${detail ? `: ${detail}` : ""}`)),
),
)
}),
)
}
function send(url: string, init: RequestInit) {
return Effect.tryPromise({
try: (signal) => fetch(url, { ...init, signal }),
catch: (cause) => cause,
})
}
function decode<S extends Schema.Decoder<unknown>>(response: Response, schema: S) {
return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(schema)))
}
function credential(
methodID: Integration.MethodID,
tokens: Token,
currentRefresh?: string,
metadata?: Readonly<Record<string, unknown>>,
) {
const refresh = tokens.refresh_token ?? currentRefresh
if (!refresh) return Effect.fail(new Error("xAI token response is missing refresh_token"))
return Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
refresh,
access: tokens.access_token,
expires: Date.now() + positiveSeconds(tokens.expires_in, 3600) * 1000,
metadata,
}),
)
}
function headers() {
return {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": `opencode/${InstallationVersion}`,
}
}
function positiveSeconds(value: unknown, fallback: number) {
const seconds = Number(value)
return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback
}
async function generatePKCE(): Promise<Pkce> {
const verifier = randomString(64)
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
"base64url",
)
return { verifier, challenge }
}
function randomString(length: number) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
}
function authorizeURL(pkce: Pkce, state: string, nonce: string) {
return `${issuer}/authorize?${new URLSearchParams({
response_type: "code",
client_id: clientID,
redirect_uri: redirectURI,
scope,
code_challenge: pkce.challenge,
code_challenge_method: "S256",
state,
nonce,
plan: "generic",
referrer: "opencode",
})}`
}
+22 -10
View File
@@ -218,14 +218,16 @@ export function integrationHost(integration: Integration.Interface): PluginConte
method: {
list: (id) => draft.method.list(Integration.ID.make(id)).map(method),
update: (input) => {
if ("authorize" in input) {
const methodID = Integration.MethodID.make(input.method.id)
const refresh = input.refresh
if (input.method.type === "oauth") {
const oauth =
input as import("@opencode-ai/plugin/v2/effect/integration").IntegrationOAuthMethodRegistration
const methodID = Integration.MethodID.make(oauth.method.id)
const refresh = oauth.refresh
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: methodID },
authorize: (inputs) =>
input.authorize(inputs).pipe(
integrationID: Integration.ID.make(oauth.integrationID),
method: { ...oauth.method, id: methodID },
authorize: (inputs: import("@opencode-ai/plugin/v2/effect/integration").IntegrationInputs) =>
oauth.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
@@ -267,7 +269,7 @@ export function integrationHost(integration: Integration.Interface): PluginConte
),
}
: {}),
...(input.label ? { label: input.label } : {}),
...(oauth.label ? { label: oauth.label } : {}),
})
return
}
@@ -278,9 +280,19 @@ export function integrationHost(integration: Integration.Interface): PluginConte
})
return
}
const registration =
input as import("@opencode-ai/plugin/v2/effect/integration").IntegrationKeyMethodRegistration
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: input.method,
integrationID: Integration.ID.make(registration.integrationID),
method: registration.method,
...(registration.authorize
? {
authorize: (key, inputs) =>
registration.authorize!(key, inputs).pipe(
Effect.map((credential) => Credential.Key.make(credential)),
),
}
: {}),
})
},
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
@@ -3,6 +3,7 @@ import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -60,6 +61,55 @@ function fakeSelectorSdk(calls: string[]) {
}
describe("AzurePlugin", () => {
it.effect("registers an API key method and stores prompted resource metadata", () =>
withEnv({ AZURE_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
expect((yield* integrations.get(Integration.ID.make("azure")))?.methods).toEqual([
{
type: "key",
label: "API key",
prompts: [
{
type: "text",
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
},
],
},
])
yield* integrations.connection.key({
integrationID: Integration.ID.make("azure"),
key: "secret",
inputs: { resourceName: "my-models" },
})
const connection = required(yield* integrations.connection.active(Integration.ID.make("azure")))
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
type: "key",
key: "secret",
metadata: { resourceName: "my-models" },
})
}),
),
)
it.effect("omits the resource prompt when Azure resource env is configured", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
expect((yield* integrations.get(Integration.ID.make("azure")))?.methods).toContainEqual({
type: "key",
label: "API key",
prompts: [],
})
}),
),
)
it.effect("resolves resourceName from env", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
@@ -1,6 +1,7 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -102,6 +103,69 @@ mock.module("ai-gateway-provider/providers/unified", () => ({
}))
describe("CloudflareAIGatewayPlugin", () => {
it.effect("registers a gateway token method and stores prompted gateway metadata", () =>
withEnv(
cloudflareEnv({
CLOUDFLARE_ACCOUNT_ID: undefined,
CLOUDFLARE_GATEWAY_ID: undefined,
CLOUDFLARE_API_TOKEN: undefined,
}),
() =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
const integrationID = Integration.ID.make("cloudflare-ai-gateway")
expect((yield* integrations.get(integrationID))?.methods).toEqual([
{
type: "key",
label: "Gateway API token",
prompts: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
{
type: "text",
key: "gatewayId",
message: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
},
],
},
])
yield* integrations.connection.key({
integrationID,
key: "secret",
inputs: { accountId: "acct", gatewayId: "gateway" },
})
const connection = yield* integrations.connection.active(integrationID)
if (!connection) throw new Error("Expected connection")
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
type: "key",
key: "secret",
metadata: { accountId: "acct", gatewayId: "gateway" },
})
}),
),
)
it.effect("omits gateway prompts backed by Cloudflare env", () =>
withEnv(cloudflareEnv(), () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
type: "key",
label: "Gateway API token",
prompts: [],
})
}),
),
)
it.effect("requires account, gateway, and token before creating the unified SDK", () =>
withEnv(
{
@@ -2,6 +2,7 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -79,6 +80,56 @@ function cloudflareHeaders(sdk: unknown, modelID = "@cf/model") {
}
describe("CloudflareWorkersAIPlugin", () => {
it.effect("registers an API key method and stores prompted account metadata", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
const integrationID = Integration.ID.make("cloudflare-workers-ai")
expect((yield* integrations.get(integrationID))?.methods).toEqual([
{
type: "key",
label: "API key",
prompts: [
{
type: "text",
key: "accountId",
message: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
},
],
},
])
yield* integrations.connection.key({
integrationID,
key: "secret",
inputs: { accountId: "acct" },
})
const connection = required(yield* integrations.connection.active(integrationID))
expect(yield* integrations.connection.resolve(connection)).toMatchObject({
type: "key",
key: "secret",
metadata: { accountId: "acct" },
})
}),
),
)
it.effect("omits the account prompt when Cloudflare account env is configured", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
yield* addPlugin()
expect((yield* integrations.get(Integration.ID.make("cloudflare-workers-ai")))?.methods).toContainEqual({
type: "key",
label: "API key",
prompts: [],
})
}),
),
)
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
@@ -0,0 +1,95 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { DigitalOceanPlugin } from "@opencode-ai/core/plugin/provider/digitalocean"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const integrationID = Integration.ID.make("digitalocean")
const providerID = ProviderV2.ID.make("digitalocean")
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
yield* DigitalOceanPlugin.effect(host)
})
describe("DigitalOceanPlugin", () => {
it.effect("registers implicit OAuth and manual model access keys", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(integrationID))?.methods).toEqual([
{
id: Integration.MethodID.make("implicit"),
type: "oauth",
label: "Login with DigitalOcean",
},
{ type: "key", label: "Paste Model Access Key" },
])
}),
)
it.effect("adds cached inference routers to the DigitalOcean catalog", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.name = "DigitalOcean"
})
})
yield* credentials.create({
integrationID,
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("implicit"),
refresh: "token",
access: "token",
expires: Date.now() + 60_000,
metadata: {
routers: [
{ name: "production", uuid: "router-1" },
{ name: "support", description: "Support router" },
],
routersFetchedAt: Date.now(),
},
}),
})
yield* addPlugin()
const model = yield* catalog.model.get(providerID, ModelV2.ID.make("router:production"))
expect(model).toMatchObject({
id: "router:production",
modelID: "router:production",
providerID: "digitalocean",
name: "production",
family: "digitalocean-inference-routers",
package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://inference.do-ai.run/v1" },
capabilities: { tools: true, input: ["text"], output: ["text"] },
limit: { context: 128_000, output: 8_192 },
})
expect(yield* catalog.model.get(providerID, ModelV2.ID.make("router:support"))).toBeDefined()
}),
)
it.effect("stores a manually registered model access key", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
yield* integrations.connection.key({ integrationID, key: "model-access-key" })
expect((yield* (yield* Credential.Service).list(integrationID))[0]?.value).toEqual({
type: "key",
key: "model-access-key",
})
}),
)
})
@@ -1,4 +1,6 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -41,6 +43,20 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
)
}
function eventually<A>(
effect: Effect.Effect<A>,
predicate: (value: A) => boolean,
remaining = 1000,
): Effect.Effect<A, Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
})
}
void mock.module("gitlab-ai-provider", () => ({
VERSION: "test-version",
createGitLab: (options: Record<string, unknown>) => {
@@ -55,6 +71,155 @@ void mock.module("gitlab-ai-provider", () => ({
}))
describe("GitLabPlugin", () => {
it.effect("registers OAuth, PAT, and environment methods", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(Integration.ID.make("gitlab")))?.methods).toEqual([
{
id: Integration.MethodID.make("oauth"),
type: "oauth",
label: "GitLab OAuth",
prompts: [
{
type: "text",
key: "instanceUrl",
message: "GitLab instance URL",
placeholder: "https://gitlab.com",
},
],
},
{
type: "key",
label: "GitLab Personal Access Token",
prompts: [
{
type: "text",
key: "instanceUrl",
message: "GitLab instance URL",
placeholder: "https://gitlab.com",
},
],
},
{ type: "env", names: ["GITLAB_TOKEN"] },
])
}),
)
it.effect("validates PATs and stores normalized instance URL metadata", () =>
Effect.gen(function* () {
yield* addPlugin()
const original = globalThis.fetch
const calls: [string, RequestInit | undefined][] = []
globalThis.fetch = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
calls.push([String(input), init])
return new Response(JSON.stringify({ id: 1 }), { status: 200 })
},
{ preconnect: original.preconnect },
)
yield* Effect.addFinalizer(() => Effect.sync(() => (globalThis.fetch = original)))
const integrations = yield* Integration.Service
yield* integrations.connection.key({
integrationID: Integration.ID.make("gitlab"),
key: "glpat-test",
inputs: { instanceUrl: "https://gitlab.example/path/" },
})
expect(calls).toHaveLength(1)
expect(calls[0]?.[0]).toBe("https://gitlab.example/api/v4/user")
expect(calls[0]?.[1]?.headers).toEqual({ Authorization: "Bearer glpat-test" })
expect((yield* (yield* Credential.Service).list(Integration.ID.make("gitlab")))[0]?.value).toEqual({
type: "key",
key: "glpat-test",
metadata: { instanceUrl: "https://gitlab.example" },
})
}),
)
it.effect("rejects invalid PAT instance URLs before validation", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const exit = yield* integrations.connection
.key({
integrationID: Integration.ID.make("gitlab"),
key: "glpat-test",
inputs: { instanceUrl: "file:///tmp/gitlab" },
})
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
expect(yield* (yield* Credential.Service).list(Integration.ID.make("gitlab"))).toHaveLength(0)
}),
)
it.effect("completes OAuth PKCE and refreshes with instance metadata", () =>
withEnv({ GITLAB_OAUTH_CLIENT_ID: "test-client" }, () =>
Effect.gen(function* () {
yield* addPlugin()
const original = globalThis.fetch
const tokenBodies: URLSearchParams[] = []
globalThis.fetch = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
if (String(input).startsWith("http://127.0.0.1:8080/")) return original(input, init)
tokenBodies.push(new URLSearchParams(String(init?.body)))
return Response.json({
access_token: tokenBodies.length === 1 ? "access" : "refreshed-access",
refresh_token: tokenBodies.length === 1 ? "refresh" : "rotated-refresh",
expires_in: 1,
})
},
{ preconnect: original.preconnect },
)
yield* Effect.addFinalizer(() => Effect.sync(() => (globalThis.fetch = original)))
const integrations = yield* Integration.Service
const attempt = yield* integrations.connection.oauth({
integrationID: Integration.ID.make("gitlab"),
methodID: Integration.MethodID.make("oauth"),
inputs: { instanceUrl: "http://gitlab.example/path" },
})
const authorize = new URL(attempt.url)
expect(authorize.origin).toBe("http://gitlab.example")
expect(authorize.pathname).toBe("/oauth/authorize")
expect(authorize.searchParams.get("client_id")).toBe("test-client")
expect(authorize.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8080/callback")
expect(authorize.searchParams.get("code_challenge_method")).toBe("S256")
expect(authorize.searchParams.get("code_challenge")).toBeTruthy()
yield* Effect.promise(() =>
fetch(`http://127.0.0.1:8080/callback?code=test-code&state=${authorize.searchParams.get("state")}`),
)
yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete")
const saved = (yield* (yield* Credential.Service).list(Integration.ID.make("gitlab")))[0]
expect(saved?.value).toEqual({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "access",
refresh: "refresh",
expires: expect.any(Number),
metadata: { instanceUrl: "http://gitlab.example" },
})
expect(tokenBodies[0]?.get("grant_type")).toBe("authorization_code")
expect(tokenBodies[0]?.get("code_verifier")).toBeTruthy()
if (saved?.value.type !== "oauth") throw new Error("Expected OAuth credential")
yield* (yield* Credential.Service).update(saved.id, { value: { ...saved.value, expires: 0 } })
const resolved = yield* integrations.connection.resolve({
type: "credential",
id: saved!.id,
label: saved!.label,
})
expect(resolved).toMatchObject({
type: "oauth",
access: "refreshed-access",
refresh: "rotated-refresh",
metadata: { instanceUrl: "http://gitlab.example" },
})
expect(tokenBodies[1]?.get("grant_type")).toBe("refresh_token")
expect(tokenBodies[1]?.get("refresh_token")).toBe("refresh")
}),
),
)
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
withEnv(
{
@@ -0,0 +1,66 @@
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PoePlugin } from "@opencode-ai/core/plugin/provider/poe"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const integrationID = Integration.ID.make("poe")
const methodID = Integration.MethodID.make("browser")
const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
yield* PoePlugin.effect(host)
})
describe("PoePlugin", () => {
it.effect("registers browser OAuth and manual API key methods", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const integration = yield* integrations.get(integrationID)
expect(integration?.name).toBe("Poe")
expect(integration?.methods).toEqual([
{ id: methodID, type: "oauth", label: "Login with Poe (browser)" },
{ type: "key", label: "Manually enter API Key" },
])
}),
)
it.effect("stores manually entered Poe API keys", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
yield* integrations.connection.key({ integrationID, key: "poe-test" })
expect((yield* credentials.list(integrationID))[0]?.value).toEqual({ type: "key", key: "poe-test" })
}),
)
it.effect("starts a PKCE browser authorization on an ephemeral loopback server", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
const url = new URL(attempt.url)
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "")
expect(url.origin + url.pathname).toBe("https://poe.com/oauth/authorize")
expect(url.searchParams.get("response_type")).toBe("code")
expect(url.searchParams.get("client_id")).toBe("client_728290227fc048cc9262091a1ea197ea")
expect(url.searchParams.get("scope")).toBe("apikey:create")
expect(url.searchParams.get("code_challenge_method")).toBe("S256")
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(redirect.hostname).toBe("127.0.0.1")
expect(redirect.pathname).toBe("/callback")
expect(Number(redirect.port)).toBeGreaterThan(0)
yield* integrations.attempt.cancel(attempt.attemptID)
}),
)
})
@@ -1,10 +1,11 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Integration } from "@opencode-ai/core/integration"
import { describe, expect, it as bun_it } from "bun:test"
import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
import { SnowflakeCortexPlugin, cortexFetch, oauthScope } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
@@ -41,6 +42,61 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
}
describe("SnowflakeCortexPlugin", () => {
it.effect("registers browser OAuth, key, and environment methods", () =>
Effect.gen(function* () {
yield* addPlugin()
expect((yield* (yield* Integration.Service).get(Integration.ID.make("snowflake-cortex")))?.methods).toEqual([
{
id: Integration.MethodID.make("snowflake-browser"),
type: "oauth",
label: "Login with Snowflake (External Browser)",
prompts: [
{
type: "text",
key: "account",
message: "Snowflake Account Identifier",
placeholder: "myorg-myaccount",
},
{
type: "text",
key: "role",
message: "Snowflake Role (optional)",
placeholder: "PUBLIC",
},
],
},
{ type: "key", label: "Paste PAT or bearer token manually" },
{ type: "env", names: ["SNOWFLAKE_CORTEX_TOKEN", "SNOWFLAKE_CORTEX_PAT"] },
])
}),
)
it.effect("uses account metadata to derive the Cortex endpoint", () =>
withEnv({ SNOWFLAKE_ACCOUNT: undefined }, () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
package: "aisdk:test-provider",
}),
package: "@ai-sdk/openai-compatible",
options: { account: "https://myorg-myaccount.snowflakecomputing.com/", apiKey: "test-pat" },
})
expect(result.options.baseURL).toBe("https://myorg-myaccount.snowflakecomputing.com/api/v2/cortex/v1")
}),
),
)
it.effect("uses Snowflake-compatible OAuth scopes", () =>
Effect.sync(() => {
expect(oauthScope(undefined)).toBe("refresh_token")
expect(oauthScope("PUBLIC")).toBe("refresh_token session:role:PUBLIC")
expect(oauthScope("AUTH SNOWFLAKE")).toBe("refresh_token session:role-encoded:AUTH%20SNOWFLAKE")
}),
)
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex")
@@ -194,6 +250,7 @@ describe("cortexFetch", () => {
const body = JSON.parse(captured[0].body as string)
expect(body.max_completion_tokens).toBe(1024)
expect(body.max_tokens).toBeUndefined()
expect(new Headers(captured[0].headers).get("User-Agent")).toMatch(/^opencode\//)
})
bun_it("preserves body when max_tokens is absent", async () => {
+37 -1
View File
@@ -1,4 +1,6 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
@@ -16,7 +18,8 @@ const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* XAIPlugin.effect(host)
const integration = yield* Integration.Service
yield* XAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
})
function fakeSelectorSdk(calls: string[]) {
@@ -33,6 +36,39 @@ function fakeSelectorSdk(calls: string[]) {
}
describe("XAIPlugin", () => {
it.effect("registers browser OAuth, device OAuth, and API key methods", () =>
Effect.gen(function* () {
yield* addPlugin()
const integration = yield* (yield* Integration.Service).get(Integration.ID.make("xai"))
expect(integration?.name).toBe("xAI")
expect(integration?.methods).toEqual([
{
id: Integration.MethodID.make("browser"),
type: "oauth",
label: "xAI Grok OAuth (SuperGrok Subscription)",
},
{
id: Integration.MethodID.make("device"),
type: "oauth",
label: "xAI Grok OAuth (Headless / Remote / VPS)",
},
{ type: "key", label: "Manually enter API Key" },
])
}),
)
it.effect("stores API keys through the registered key method", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
yield* integrations.connection.key({ integrationID: Integration.ID.make("xai"), key: "xai-test" })
expect((yield* (yield* Credential.Service).list(Integration.ID.make("xai")))[0]?.value).toEqual({
type: "key",
key: "xai-test",
})
}),
)
it.effect("creates an xAI SDK only for @ai-sdk/xai", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
+14 -8
View File
@@ -1,5 +1,6 @@
import type {
ConnectionInfo,
CredentialKey,
CredentialOAuth,
CredentialValue,
IntegrationEnvMethod,
@@ -13,6 +14,8 @@ import type { IntegrationApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect"
import type { Transform } from "./registration.js"
export type { IntegrationInputs }
export type IntegrationOAuthAuthorization = {
readonly url: string
readonly instructions: string
@@ -33,16 +36,19 @@ export type IntegrationOAuthMethodRegistration = {
readonly refresh?: (credential: CredentialOAuth) => Effect.Effect<CredentialOAuth, unknown>
readonly label?: (credential: CredentialOAuth) => string | undefined
}
export type IntegrationKeyMethodRegistration = {
readonly integrationID: string
readonly method: IntegrationKeyMethod
readonly authorize?: (key: string, inputs: IntegrationInputs) => Effect.Effect<CredentialKey, unknown>
}
export type IntegrationEnvMethodRegistration = {
readonly integrationID: string
readonly method: IntegrationEnvMethod
}
export type IntegrationMethodRegistration =
| IntegrationOAuthMethodRegistration
| {
readonly integrationID: string
readonly method: IntegrationKeyMethod
}
| {
readonly integrationID: string
readonly method: IntegrationEnvMethod
}
| IntegrationKeyMethodRegistration
| IntegrationEnvMethodRegistration
export interface IntegrationDraft {
list(): readonly IntegrationRef[]
@@ -43,6 +43,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
query: LocationQuery,
payload: Schema.Struct({
key: Schema.String,
inputs: Schema.optional(Inputs),
label: Schema.optional(Schema.String),
}),
success: HttpApiSchema.NoContent,
+1
View File
@@ -60,6 +60,7 @@ export interface KeyMethod extends Schema.Schema.Type<typeof KeyMethod> {}
export const KeyMethod = Schema.Struct({
type: Schema.Literal("key"),
label: optional(Schema.String),
prompts: optional(Schema.Array(Prompt)),
}).annotate({ identifier: "Integration.KeyMethod" })
export interface EnvMethod extends Schema.Schema.Type<typeof EnvMethod> {}
+4
View File
@@ -6772,6 +6772,9 @@ export class Connect extends HeyApiClient {
workspace?: string | null
} | null
key?: string
inputs?: {
[key: string]: string
} | null
label?: string | null
},
options?: Options<never, ThrowOnError>,
@@ -6784,6 +6787,7 @@ export class Connect extends HeyApiClient {
{ in: "path", key: "integrationID" },
{ in: "query", key: "location" },
{ in: "body", key: "key" },
{ in: "body", key: "inputs" },
{ in: "body", key: "label" },
],
},
+80 -94
View File
@@ -598,24 +598,6 @@ export type Part =
| RetryPart
| CompactionPart
export type Shell = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
}
export type Pty = {
id: string
title: string
@@ -917,7 +899,7 @@ export type GlobalEvent = {
type: "session.shell.started"
properties: {
sessionID: string
shell: Shell
shell: ShellInfo
}
}
| {
@@ -925,7 +907,7 @@ export type GlobalEvent = {
type: "session.shell.ended"
properties: {
sessionID: string
shell: Shell
shell: ShellInfo
output: {
output: string
cursor: number
@@ -1358,7 +1340,7 @@ export type GlobalEvent = {
id: string
type: "shell.created"
properties: {
info: Shell
info: ShellInfo
}
}
| {
@@ -1366,7 +1348,7 @@ export type GlobalEvent = {
type: "shell.exited"
properties: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -2890,24 +2872,6 @@ export type InstructionEntryValueTooLargeError = {
message: string
}
export type Shell1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity"
completed?: number | "NaN" | "Infinity" | "-Infinity"
}
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionLogItemStream = string
@@ -3154,24 +3118,6 @@ export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
export type Shell2 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity"
completed?: number | "NaN" | "Infinity" | "-Infinity"
}
}
export type EventTuiPromptAppend2 = {
id: string
type: "tui.prompt.append"
@@ -3398,6 +3344,24 @@ export type SessionStructuredError = {
message: string
}
export type ShellInfo = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number
metadata: {
[key: string]: unknown
}
time: {
started: number
completed?: number
}
}
export type SessionMessageProviderState = {
[key: string]: unknown
}
@@ -3962,7 +3926,7 @@ export type SyncEventSessionShellStarted = {
aggregateID: string
data: {
sessionID: string
shell: Shell
shell: ShellInfo
}
}
}
@@ -3977,7 +3941,7 @@ export type SyncEventSessionShellEnded = {
aggregateID: string
data: {
sessionID: string
shell: Shell
shell: ShellInfo
output: {
output: string
cursor: number
@@ -5083,7 +5047,7 @@ export type SessionShellStarted = {
location?: LocationRef
data: {
sessionID: string
shell: Shell1
shell: ShellInfo
}
}
@@ -5102,7 +5066,7 @@ export type SessionShellEnded = {
location?: LocationRef
data: {
sessionID: string
shell: Shell1
shell: ShellInfo
output: {
output: string
cursor: number
@@ -5730,6 +5694,7 @@ export type IntegrationOAuthMethod = {
export type IntegrationKeyMethod = {
type: "key"
label?: string
prompts?: Array<IntegrationTextPrompt | IntegrationSelectPrompt>
}
export type IntegrationEnvMethod = {
@@ -6461,7 +6426,7 @@ export type ShellCreated = {
type: "shell.created"
location?: LocationRef
data: {
info: Shell1
info: ShellInfo
}
}
@@ -6475,7 +6440,7 @@ export type ShellExited = {
location?: LocationRef
data: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -7285,7 +7250,7 @@ export type EventSessionShellStarted = {
type: "session.shell.started"
properties: {
sessionID: string
shell: Shell2
shell: ShellInfo
}
}
@@ -7294,7 +7259,7 @@ export type EventSessionShellEnded = {
type: "session.shell.ended"
properties: {
sessionID: string
shell: Shell2
shell: ShellInfo
output: {
output: string
cursor: number
@@ -7772,7 +7737,7 @@ export type EventShellCreated = {
id: string
type: "shell.created"
properties: {
info: Shell2
info: ShellInfo
}
}
@@ -7781,7 +7746,7 @@ export type EventShellExited = {
type: "shell.exited"
properties: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -8179,24 +8144,6 @@ export type UnknownErrorV2 = {
ref?: string | null
}
export type ShellV2 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number | "NaN" | "Infinity" | "-Infinity"
metadata: {
[key: string]: unknown
}
time: {
started: number | "NaN" | "Infinity" | "-Infinity"
completed?: number | "NaN" | "Infinity" | "-Infinity"
}
}
export type SessionMessagesResponseV2 = {
data: Array<SessionMessageInfo>
cursor: {
@@ -9333,6 +9280,24 @@ export type SessionSkillActivatedV2 = {
}
}
export type ShellInfoV2 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number
metadata: {
[key: string]: unknown
}
time: {
started: number
completed?: number
}
}
export type SessionShellStartedV2 = {
id: string
created: number
@@ -9348,7 +9313,7 @@ export type SessionShellStartedV2 = {
location?: LocationRefV2
data: {
sessionID: string
shell: ShellV2
shell: ShellInfoV2
}
}
@@ -9367,7 +9332,7 @@ export type SessionShellEndedV2 = {
location?: LocationRefV2
data: {
sessionID: string
shell: ShellV2
shell: ShellInfoV2
output: {
output: string
cursor: number
@@ -10514,7 +10479,7 @@ export type ShellCreatedV2 = {
type: "shell.created"
location?: LocationRefV2
data: {
info: ShellV2
info: ShellInfoV2
}
}
@@ -10528,7 +10493,7 @@ export type ShellExitedV2 = {
location?: LocationRefV2
data: {
id: string
exit?: number | "NaN" | "Infinity" | "-Infinity"
exit?: number
status: "running" | "exited" | "timeout" | "killed"
}
}
@@ -10986,6 +10951,24 @@ export type PtyTicketConnectTokenV2 = {
expires_in: number
}
export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
command: string
cwd: string
shell: string
file: string
pid?: number
exit?: number
metadata: {
[key: string]: unknown
}
time: {
started: number
completed?: number
}
}
export type QuestionV2RequestV2 = {
id: string
sessionID: string
@@ -16783,6 +16766,9 @@ export type V2IntegrationGetResponse = V2IntegrationGetResponses[keyof V2Integra
export type V2IntegrationConnectKeyData = {
body: {
key: string
inputs?: {
[key: string]: string
} | null
label?: string | null
}
path: {
@@ -18318,7 +18304,7 @@ export type V2ShellListResponses = {
*/
200: {
location: LocationInfoV2
data: Array<ShellV2>
data: Array<ShellInfo1>
}
}
@@ -18362,7 +18348,7 @@ export type V2ShellCreateResponses = {
*/
200: {
location: LocationInfoV2
data: ShellV2
data: ShellInfo1
}
}
@@ -18445,7 +18431,7 @@ export type V2ShellGetResponses = {
*/
200: {
location: LocationInfoV2
data: ShellV2
data: ShellInfo1
}
}
@@ -18490,7 +18476,7 @@ export type V2ShellTimeoutResponses = {
*/
200: {
location: LocationInfoV2
data: ShellV2
data: ShellInfo1
}
}
@@ -41,6 +41,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
service.connection.key({
integrationID: ctx.params.integrationID,
key: ctx.payload.key,
inputs: ctx.payload.inputs,
label: ctx.payload.label,
}),
)
@@ -159,15 +159,29 @@ function openMethod(
onConnected?: OnIntegrationConnected,
) {
if (method.type === "key") {
dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />)
void beginKey(integration, method, dialog, onConnected)
return
}
void beginOAuth(integration, method, dialog, onConnected)
}
async function beginKey(
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "key" }>,
dialog: ReturnType<typeof useDialog>,
onConnected?: OnIntegrationConnected,
) {
const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {}
if (inputs === null) return
dialog.replace(() => (
<KeyMethod integration={integration} method={method} inputs={inputs} onConnected={onConnected} />
))
}
function KeyMethod(props: {
integration: IntegrationInfo
method: Extract<ConnectMethod, { type: "key" }>
inputs: Record<string, string>
onConnected?: OnIntegrationConnected
}) {
const data = useData()
@@ -183,11 +197,12 @@ function KeyMethod(props: {
placeholder="API key"
onConfirm={(key) => {
if (!key) return
void sdk.api.integration
.connect.key({
void sdk.api.integration.connect
.key({
integrationID: props.integration.id,
location: location(data),
key,
inputs: props.inputs,
})
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
.catch((cause) => setError(message(cause)))
@@ -222,8 +237,8 @@ function OAuthStarting(props: {
const toast = useToast()
onMount(() => {
void sdk.api.integration
.connect.oauth({
void sdk.api.integration.connect
.oauth({
integrationID: props.integration.id,
location: location(data),
methodID: props.method.id,
@@ -291,8 +306,8 @@ function OAuthAuto(props: {
}))
const poll = () => {
void sdk.api.integration
.attempt.status({ attemptID: props.attempt.attemptID, location: location(data) })
void sdk.api.integration.attempt
.status({ attemptID: props.attempt.attemptID, location: location(data) })
.then((result) => {
const status = result.data
if (status.status === "pending") {
@@ -357,8 +372,8 @@ function OAuthCode(props: {
placeholder="Authorization code"
onConfirm={(code) => {
if (!code) return
void sdk.api.integration
.attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
void sdk.api.integration.attempt
.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
.then(() => {
settled = true
return connected(props.integration, data, dialog, toast, props.onConnected)