mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 10:45:33 -04:00
feat(core): port provider integrations to v2
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {})
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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("")
|
||||
}
|
||||
|
||||
@@ -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("")
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})}`
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user