feat(core): add opencode integration (#33555)
This commit is contained in:
parent
c17b9557f1
commit
cf80b5c470
@ -20,13 +20,13 @@ export class OAuth extends Schema.Class<OAuth>("Credential.OAuth")({
|
|||||||
refresh: Schema.String,
|
refresh: Schema.String,
|
||||||
access: Schema.String,
|
access: Schema.String,
|
||||||
expires: NonNegativeInt,
|
expires: NonNegativeInt,
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class Key extends Schema.Class<Key>("Credential.Key")({
|
export class Key extends Schema.Class<Key>("Credential.Key")({
|
||||||
type: Schema.Literal("key"),
|
type: Schema.Literal("key"),
|
||||||
key: Schema.String,
|
key: Schema.String,
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const Value = Schema.Union([OAuth, Key])
|
export const Value = Schema.Union([OAuth, Key])
|
||||||
|
|||||||
@ -90,7 +90,9 @@ export const EnvMethod = Schema.Struct({
|
|||||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||||
export type EnvMethod = typeof EnvMethod.Type
|
export type EnvMethod = typeof EnvMethod.Type
|
||||||
|
|
||||||
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
|
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod])
|
||||||
|
.pipe(Schema.toTaggedUnion("type"))
|
||||||
|
.annotate({ identifier: "Integration.Method" })
|
||||||
export type Method = typeof Method.Type
|
export type Method = typeof Method.Type
|
||||||
|
|
||||||
export class Info extends Schema.Class<Info>("Integration.Info")({
|
export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||||
@ -100,7 +102,8 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
|
|||||||
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
|
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export type Inputs = Readonly<{ [key: string]: string }>
|
export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" })
|
||||||
|
export type Inputs = typeof Inputs.Type
|
||||||
|
|
||||||
export type OAuthAuthorization = {
|
export type OAuthAuthorization = {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
@ -108,11 +111,11 @@ export type OAuthAuthorization = {
|
|||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
readonly mode: "auto"
|
readonly mode: "auto"
|
||||||
readonly callback: Effect.Effect<Credential.Value, unknown>
|
readonly callback: Effect.Effect<Credential.OAuth, unknown>
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly mode: "code"
|
readonly mode: "code"
|
||||||
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
|
readonly callback: (code: string) => Effect.Effect<Credential.OAuth, unknown>
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -121,6 +124,7 @@ export interface OAuthImplementation {
|
|||||||
readonly method: OAuthMethod
|
readonly method: OAuthMethod
|
||||||
readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
|
readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
|
||||||
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
|
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
|
||||||
|
readonly label?: (credential: Credential.OAuth) => string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeyImplementation {
|
export interface KeyImplementation {
|
||||||
@ -178,12 +182,17 @@ export const Event = {
|
|||||||
type: "integration.updated",
|
type: "integration.updated",
|
||||||
schema: {},
|
schema: {},
|
||||||
}),
|
}),
|
||||||
|
ConnectionUpdated: EventV2.define({
|
||||||
|
type: "integration.connection.updated",
|
||||||
|
schema: { integrationID: ID },
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Ref = {
|
export const Ref = Schema.Struct({
|
||||||
id: ID
|
id: ID,
|
||||||
name: string
|
name: Schema.String,
|
||||||
}
|
}).annotate({ identifier: "Integration.Ref" })
|
||||||
|
export type Ref = typeof Ref.Type
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
ref: Types.DeepMutable<Ref>
|
ref: Types.DeepMutable<Ref>
|
||||||
@ -215,7 +224,11 @@ export interface Interface extends State.Transformable<Draft> {
|
|||||||
readonly list: () => Effect.Effect<Info[]>
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
readonly connection: {
|
readonly connection: {
|
||||||
/** Returns the active connection for one integration. */
|
/** Returns the active connection for one integration. */
|
||||||
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
readonly active: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||||
|
/** Resolves a connection into usable credential material. */
|
||||||
|
readonly resolve: (
|
||||||
|
connection: IntegrationConnection.Info,
|
||||||
|
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
|
||||||
/** Runs a key method and stores the resulting credential. */
|
/** Runs a key method and stores the resulting credential. */
|
||||||
readonly key: (input: {
|
readonly key: (input: {
|
||||||
/** Integration receiving the credential. */
|
/** Integration receiving the credential. */
|
||||||
@ -385,7 +398,7 @@ export const locationLayer = Layer.effect(
|
|||||||
return error instanceof Error ? error.message : String(error)
|
return error instanceof Error ? error.message : String(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
|
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
|
||||||
const now = yield* Clock.currentTimeMillis
|
const now = yield* Clock.currentTimeMillis
|
||||||
const result = yield* SynchronizedRef.modify(attempts, (current) => {
|
const result = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||||
const attempt = current.get(attemptID)
|
const attempt = current.get(attemptID)
|
||||||
@ -397,14 +410,13 @@ export const locationLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
if (!result) return
|
if (!result) return
|
||||||
if (Exit.isSuccess(exit)) {
|
if (Exit.isSuccess(exit)) {
|
||||||
|
const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID)
|
||||||
yield* credentials.create({
|
yield* credentials.create({
|
||||||
integrationID: result.integrationID,
|
integrationID: result.integrationID,
|
||||||
label: result.label,
|
label: result.label ?? implementation?.label?.(exit.value),
|
||||||
value:
|
value: exit.value,
|
||||||
exit.value.type === "oauth"
|
|
||||||
? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
|
|
||||||
: exit.value,
|
|
||||||
})
|
})
|
||||||
|
yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID })
|
||||||
yield* events.publish(Event.Updated, {})
|
yield* events.publish(Event.Updated, {})
|
||||||
}
|
}
|
||||||
yield* close(result.scope)
|
yield* close(result.scope)
|
||||||
@ -445,10 +457,29 @@ export const locationLayer = Layer.effect(
|
|||||||
).toSorted((a, b) => a.name.localeCompare(b.name))
|
).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
}),
|
}),
|
||||||
connection: {
|
connection: {
|
||||||
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
active: Effect.fn("Integration.connection.active")(function* (id) {
|
||||||
const entry = state.get().integrations.get(id)
|
const entry = state.get().integrations.get(id)
|
||||||
return resolveConnections(entry, yield* credentials.list(id))[0]
|
return resolveConnections(entry, yield* credentials.list(id))[0]
|
||||||
}),
|
}),
|
||||||
|
resolve: Effect.fn("Integration.connection.resolve")(function* (connection) {
|
||||||
|
if (connection.type === "env") {
|
||||||
|
const key = process.env[connection.name]
|
||||||
|
return key ? new Credential.Key({ type: "key", key }) : undefined
|
||||||
|
}
|
||||||
|
const credential = yield* credentials.get(connection.id)
|
||||||
|
if (!credential) return undefined
|
||||||
|
if (credential.value.type === "key") return credential.value
|
||||||
|
const implementation = state
|
||||||
|
.get()
|
||||||
|
.integrations.get(credential.integrationID)
|
||||||
|
?.implementations.get(credential.value.methodID)
|
||||||
|
if (!implementation?.refresh) return credential.value
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
|
||||||
|
const value = yield* authorize(implementation.refresh(credential.value))
|
||||||
|
yield* credentials.update(credential.id, { value })
|
||||||
|
return value
|
||||||
|
}),
|
||||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||||
const method = state
|
const method = state
|
||||||
.get()
|
.get()
|
||||||
@ -460,6 +491,7 @@ export const locationLayer = Layer.effect(
|
|||||||
label: input.label,
|
label: input.label,
|
||||||
value: new Credential.Key({ type: "key", key: input.key }),
|
value: new Credential.Key({ type: "key", key: input.key }),
|
||||||
})
|
})
|
||||||
|
yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
|
||||||
yield* events.publish(Event.Updated, {})
|
yield* events.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
|
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
|
||||||
@ -503,11 +535,19 @@ export const locationLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
||||||
|
const credential = yield* credentials.get(credentialID)
|
||||||
yield* credentials.update(credentialID, updates)
|
yield* credentials.update(credentialID, updates)
|
||||||
|
if (credential) {
|
||||||
|
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||||
|
}
|
||||||
yield* events.publish(Event.Updated, {})
|
yield* events.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
||||||
|
const credential = yield* credentials.get(credentialID)
|
||||||
yield* credentials.remove(credentialID)
|
yield* credentials.remove(credentialID)
|
||||||
|
if (credential) {
|
||||||
|
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
|
||||||
|
}
|
||||||
yield* events.publish(Event.Updated, {})
|
yield* events.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { AgentV2 } from "../agent"
|
|||||||
import { AISDK } from "../aisdk"
|
import { AISDK } from "../aisdk"
|
||||||
import { Catalog } from "../catalog"
|
import { Catalog } from "../catalog"
|
||||||
import { CommandV2 } from "../command"
|
import { CommandV2 } from "../command"
|
||||||
|
import { Credential } from "../credential"
|
||||||
import { Integration } from "../integration"
|
import { Integration } from "../integration"
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
@ -97,6 +98,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
},
|
},
|
||||||
integration: {
|
integration: {
|
||||||
reload: integration.reload,
|
reload: integration.reload,
|
||||||
|
connection: {
|
||||||
|
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
||||||
|
resolve: (connection) =>
|
||||||
|
integration.connection.resolve(
|
||||||
|
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
|
||||||
|
),
|
||||||
|
},
|
||||||
transform: (callback) =>
|
transform: (callback) =>
|
||||||
integration.transform((draft) =>
|
integration.transform((draft) =>
|
||||||
callback({
|
callback({
|
||||||
@ -107,6 +115,62 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
method: {
|
method: {
|
||||||
list: (id) => draft.method.list(Integration.ID.make(id)),
|
list: (id) => draft.method.list(Integration.ID.make(id)),
|
||||||
update: (input) => {
|
update: (input) => {
|
||||||
|
if ("authorize" in input) {
|
||||||
|
const methodID = Integration.MethodID.make(input.method.id)
|
||||||
|
const refresh = input.refresh
|
||||||
|
draft.method.update({
|
||||||
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
method: { ...input.method, id: methodID },
|
||||||
|
authorize: (inputs) =>
|
||||||
|
input.authorize(inputs).pipe(
|
||||||
|
Effect.map((authorization) => {
|
||||||
|
if (authorization.mode === "auto") {
|
||||||
|
return {
|
||||||
|
...authorization,
|
||||||
|
callback: authorization.callback.pipe(
|
||||||
|
Effect.map(
|
||||||
|
(credential) =>
|
||||||
|
new Credential.OAuth({
|
||||||
|
...credential,
|
||||||
|
methodID: Integration.MethodID.make(credential.methodID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...authorization,
|
||||||
|
callback: (code: string) =>
|
||||||
|
authorization.callback(code).pipe(
|
||||||
|
Effect.map(
|
||||||
|
(credential) =>
|
||||||
|
new Credential.OAuth({
|
||||||
|
...credential,
|
||||||
|
methodID: Integration.MethodID.make(credential.methodID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...(refresh
|
||||||
|
? {
|
||||||
|
refresh: (value: Credential.OAuth) =>
|
||||||
|
refresh(value).pipe(
|
||||||
|
Effect.map(
|
||||||
|
(next) =>
|
||||||
|
new Credential.OAuth({
|
||||||
|
...next,
|
||||||
|
methodID: Integration.MethodID.make(next.methodID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(input.label ? { label: input.label } : {}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (input.method.type === "env") {
|
if (input.method.type === "env") {
|
||||||
draft.method.update({
|
draft.method.update({
|
||||||
integrationID: Integration.ID.make(input.integrationID),
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { Npm } from "../npm"
|
|||||||
import { PluginV2 } from "../plugin"
|
import { PluginV2 } from "../plugin"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
|
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||||
import { AgentPlugin } from "./agent"
|
import { AgentPlugin } from "./agent"
|
||||||
import { CommandPlugin } from "./command"
|
import { CommandPlugin } from "./command"
|
||||||
import { ModelsDevPlugin } from "./models-dev"
|
import { ModelsDevPlugin } from "./models-dev"
|
||||||
@ -38,6 +39,7 @@ export type Requirements =
|
|||||||
| FileSystem.Service
|
| FileSystem.Service
|
||||||
| FSUtil.Service
|
| FSUtil.Service
|
||||||
| Global.Service
|
| Global.Service
|
||||||
|
| HttpClient.HttpClient
|
||||||
| Integration.Service
|
| Integration.Service
|
||||||
| Location.Service
|
| Location.Service
|
||||||
| ModelsDev.Service
|
| ModelsDev.Service
|
||||||
@ -69,6 +71,7 @@ export const locationLayer = Layer.effectDiscard(
|
|||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const filesystem = yield* FileSystem.Service
|
const filesystem = yield* FileSystem.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
const add = <R>(input: Plugin<R>) => {
|
const add = <R>(input: Plugin<R>) => {
|
||||||
@ -90,6 +93,7 @@ export const locationLayer = Layer.effectDiscard(
|
|||||||
Effect.provideService(FSUtil.Service, fs),
|
Effect.provideService(FSUtil.Service, fs),
|
||||||
Effect.provideService(FileSystem.Service, filesystem),
|
Effect.provideService(FileSystem.Service, filesystem),
|
||||||
Effect.provideService(Global.Service, global),
|
Effect.provideService(Global.Service, global),
|
||||||
|
Effect.provideService(HttpClient.HttpClient, http),
|
||||||
Effect.provideService(SkillV2.Service, skill),
|
Effect.provideService(SkillV2.Service, skill),
|
||||||
Effect.provideService(Reference.Service, reference),
|
Effect.provideService(Reference.Service, reference),
|
||||||
),
|
),
|
||||||
@ -115,4 +119,5 @@ export const locationLayer = Layer.effectDiscard(
|
|||||||
Layer.provideMerge(PluginV2.locationLayer),
|
Layer.provideMerge(PluginV2.locationLayer),
|
||||||
Layer.provideMerge(Config.locationLayer),
|
Layer.provideMerge(Config.locationLayer),
|
||||||
Layer.provideMerge(FileSystem.locationLayer),
|
Layer.provideMerge(FileSystem.locationLayer),
|
||||||
|
Layer.provideMerge(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -65,6 +65,11 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
integration: {
|
integration: {
|
||||||
transform: transform(host.integration),
|
transform: transform(host.integration),
|
||||||
reload: () => run(host.integration.reload()),
|
reload: () => run(host.integration.reload()),
|
||||||
|
connection: {
|
||||||
|
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
|
||||||
|
resolve: (connection) =>
|
||||||
|
Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
add: (input) => {
|
add: (input) => {
|
||||||
|
|||||||
@ -1,257 +0,0 @@
|
|||||||
import { createServer } from "node:http"
|
|
||||||
import { Deferred, Effect } from "effect"
|
|
||||||
import { Integration } from "../../integration"
|
|
||||||
import { Credential } from "../../credential"
|
|
||||||
import { InstallationVersion } from "../../installation/version"
|
|
||||||
|
|
||||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
||||||
const issuer = "https://auth.openai.com"
|
|
||||||
const callbackPort = 1455
|
|
||||||
const pollingSafetyMargin = 3000
|
|
||||||
|
|
||||||
type Pkce = {
|
|
||||||
verifier: string
|
|
||||||
challenge: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type TokenResponse = {
|
|
||||||
id_token: string
|
|
||||||
access_token: string
|
|
||||||
refresh_token: string
|
|
||||||
expires_in?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type Claims = {
|
|
||||||
chatgpt_account_id?: string
|
|
||||||
organizations?: Array<{ id: string }>
|
|
||||||
"https://api.openai.com/auth"?: { chatgpt_account_id?: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
|
||||||
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
|
||||||
|
|
||||||
export const browser = {
|
|
||||||
integrationID: Integration.ID.make("openai"),
|
|
||||||
method: {
|
|
||||||
id: browserMethodID,
|
|
||||||
type: "oauth",
|
|
||||||
label: "ChatGPT Pro/Plus (browser)",
|
|
||||||
},
|
|
||||||
authorize: () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const pkce = yield* Effect.promise(generatePKCE)
|
|
||||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
|
||||||
const code = yield* Deferred.make<string, Error>()
|
|
||||||
const redirect = `http://localhost:${callbackPort}/auth/callback`
|
|
||||||
const server = createServer((request, response) => {
|
|
||||||
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
|
|
||||||
if (url.pathname !== "/auth/callback") {
|
|
||||||
response.writeHead(404).end("Not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
|
||||||
const value = url.searchParams.get("code")
|
|
||||||
if (error) {
|
|
||||||
Effect.runFork(Deferred.fail(code, new Error(error)))
|
|
||||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!value || url.searchParams.get("state") !== state) {
|
|
||||||
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
|
||||||
Effect.runFork(Deferred.fail(code, new Error(message)))
|
|
||||||
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Effect.runFork(Deferred.succeed(code, value))
|
|
||||||
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
|
|
||||||
})
|
|
||||||
yield* Effect.callback<void, Error>((resume) => {
|
|
||||||
server.once("error", (error) => resume(Effect.fail(error)))
|
|
||||||
server.listen(callbackPort, "localhost", () => resume(Effect.void))
|
|
||||||
})
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
server.close()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
mode: "auto" as const,
|
|
||||||
url: authorizeURL(redirect, pkce, state),
|
|
||||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
|
||||||
callback: Deferred.await(code).pipe(
|
|
||||||
Effect.flatMap((value) => exchange(value, redirect, pkce)),
|
|
||||||
Effect.map((tokens) => credential(browserMethodID, tokens)),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
refresh: (value) => refresh(value),
|
|
||||||
} satisfies Integration.OAuthImplementation
|
|
||||||
|
|
||||||
export const headless = {
|
|
||||||
integrationID: Integration.ID.make("openai"),
|
|
||||||
method: {
|
|
||||||
id: headlessMethodID,
|
|
||||||
type: "oauth",
|
|
||||||
label: "ChatGPT Pro/Plus (headless)",
|
|
||||||
},
|
|
||||||
authorize: () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
|
|
||||||
`${issuer}/api/accounts/deviceauth/usercode`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: headers("application/json"),
|
|
||||||
body: JSON.stringify({ client_id: clientID }),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
|
|
||||||
return {
|
|
||||||
mode: "auto" as const,
|
|
||||||
url: `${issuer}/codex/device`,
|
|
||||||
instructions: `Enter code: ${device.user_code}`,
|
|
||||||
callback: Effect.gen(function* () {
|
|
||||||
while (true) {
|
|
||||||
const response = yield* Effect.tryPromise({
|
|
||||||
try: (signal) =>
|
|
||||||
fetch(`${issuer}/api/accounts/deviceauth/token`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: headers("application/json"),
|
|
||||||
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
|
|
||||||
signal,
|
|
||||||
}),
|
|
||||||
catch: (cause) => cause,
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (yield* Effect.promise(() => response.json())) as {
|
|
||||||
authorization_code: string
|
|
||||||
code_verifier: string
|
|
||||||
}
|
|
||||||
return credential(
|
|
||||||
headlessMethodID,
|
|
||||||
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
|
|
||||||
verifier: data.code_verifier,
|
|
||||||
challenge: "",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (response.status !== 403 && response.status !== 404) {
|
|
||||||
return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
|
|
||||||
}
|
|
||||||
yield* Effect.sleep(interval + pollingSafetyMargin)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
refresh: (value) => refresh(value),
|
|
||||||
} satisfies Integration.OAuthImplementation
|
|
||||||
|
|
||||||
function headers(contentType: string) {
|
|
||||||
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
function exchange(code: string, redirect: string, pkce: Pkce) {
|
|
||||||
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: headers("application/x-www-form-urlencoded"),
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
code,
|
|
||||||
redirect_uri: redirect,
|
|
||||||
client_id: clientID,
|
|
||||||
code_verifier: pkce.verifier,
|
|
||||||
}).toString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh(value: Credential.OAuth) {
|
|
||||||
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: headers("application/x-www-form-urlencoded"),
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: value.refresh,
|
|
||||||
client_id: clientID,
|
|
||||||
}).toString(),
|
|
||||||
}).pipe(
|
|
||||||
Effect.map((tokens) => {
|
|
||||||
const next = credential(value.methodID, tokens)
|
|
||||||
return new Credential.OAuth({
|
|
||||||
...next,
|
|
||||||
metadata: next.metadata ?? value.metadata,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function request<A>(url: string, init: RequestInit) {
|
|
||||||
return Effect.tryPromise({
|
|
||||||
try: async (signal) => {
|
|
||||||
const response = await fetch(url, { ...init, signal })
|
|
||||||
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
|
|
||||||
return response.json() as Promise<A>
|
|
||||||
},
|
|
||||||
catch: (cause) => cause,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function credential(methodID: Integration.MethodID, tokens: TokenResponse) {
|
|
||||||
const accountID = extractAccountID(tokens)
|
|
||||||
return new Credential.OAuth({
|
|
||||||
type: "oauth",
|
|
||||||
methodID,
|
|
||||||
refresh: tokens.refresh_token,
|
|
||||||
access: tokens.access_token,
|
|
||||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
||||||
metadata: accountID ? { accountID } : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generatePKCE(): Promise<Pkce> {
|
|
||||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
||||||
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
|
|
||||||
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
|
|
||||||
return { verifier, challenge }
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64UrlEncode(buffer: ArrayBuffer) {
|
|
||||||
return Buffer.from(buffer).toString("base64url")
|
|
||||||
}
|
|
||||||
|
|
||||||
function authorizeURL(redirect: string, pkce: Pkce, state: string) {
|
|
||||||
return `${issuer}/oauth/authorize?${new URLSearchParams({
|
|
||||||
response_type: "code",
|
|
||||||
client_id: clientID,
|
|
||||||
redirect_uri: redirect,
|
|
||||||
scope: "openid profile email offline_access",
|
|
||||||
code_challenge: pkce.challenge,
|
|
||||||
code_challenge_method: "S256",
|
|
||||||
id_token_add_organizations: "true",
|
|
||||||
codex_cli_simplified_flow: "true",
|
|
||||||
state,
|
|
||||||
originator: "opencode",
|
|
||||||
})}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractAccountID(tokens: TokenResponse) {
|
|
||||||
return claim(tokens.id_token) ?? claim(tokens.access_token)
|
|
||||||
}
|
|
||||||
|
|
||||||
function claim(token: string) {
|
|
||||||
const part = token.split(".")[1]
|
|
||||||
if (!part) return
|
|
||||||
try {
|
|
||||||
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
|
|
||||||
return (
|
|
||||||
claims.chatgpt_account_id ??
|
|
||||||
claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
|
|
||||||
claims.organizations?.[0]?.id
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const successPage =
|
|
||||||
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
|
|
||||||
const errorPage = (message: string) =>
|
|
||||||
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
|
|
||||||
@ -1,15 +1,155 @@
|
|||||||
import { Effect } from "effect"
|
import { createServer } from "node:http"
|
||||||
import { ModelV2 } from "../../model"
|
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||||
import { define } from "../internal"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { Deferred, Effect } from "effect"
|
||||||
|
import type { Scope } from "effect"
|
||||||
|
import { Credential } from "../../credential"
|
||||||
|
import { InstallationVersion } from "../../installation/version"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { browser, headless } from "./openai-auth"
|
import { ModelV2 } from "../../model"
|
||||||
|
import { ProviderV2 } from "../../provider"
|
||||||
|
import type { PluginInternal } from "../internal"
|
||||||
|
|
||||||
|
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
|
const issuer = "https://auth.openai.com"
|
||||||
|
const callbackPort = 1455
|
||||||
|
const pollingSafetyMargin = 3000
|
||||||
|
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
||||||
|
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||||
|
|
||||||
|
type Pkce = {
|
||||||
|
verifier: string
|
||||||
|
challenge: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenResponse = {
|
||||||
|
id_token: string
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
expires_in?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims = {
|
||||||
|
chatgpt_account_id?: string
|
||||||
|
organizations?: Array<{ id: string }>
|
||||||
|
"https://api.openai.com/auth"?: { chatgpt_account_id?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = {
|
||||||
|
integrationID: Integration.ID.make("openai"),
|
||||||
|
method: {
|
||||||
|
id: browserMethodID,
|
||||||
|
type: "oauth",
|
||||||
|
label: "ChatGPT Pro/Plus (browser)",
|
||||||
|
},
|
||||||
|
authorize: () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pkce = yield* Effect.promise(generatePKCE)
|
||||||
|
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||||
|
const code = yield* Deferred.make<string, Error>()
|
||||||
|
const redirect = `http://localhost:${callbackPort}/auth/callback`
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
|
||||||
|
if (url.pathname !== "/auth/callback") {
|
||||||
|
response.writeHead(404).end("Not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||||
|
const value = url.searchParams.get("code")
|
||||||
|
if (error) {
|
||||||
|
Effect.runFork(Deferred.fail(code, new Error(error)))
|
||||||
|
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!value || url.searchParams.get("state") !== state) {
|
||||||
|
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
||||||
|
Effect.runFork(Deferred.fail(code, new Error(message)))
|
||||||
|
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Effect.runFork(Deferred.succeed(code, value))
|
||||||
|
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
|
||||||
|
})
|
||||||
|
yield* Effect.callback<void, Error>((resume) => {
|
||||||
|
server.once("error", (error) => resume(Effect.fail(error)))
|
||||||
|
server.listen(callbackPort, "localhost", () => resume(Effect.void))
|
||||||
|
})
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||||
|
return {
|
||||||
|
mode: "auto" as const,
|
||||||
|
url: authorizeURL(redirect, pkce, state),
|
||||||
|
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||||
|
callback: Deferred.await(code).pipe(
|
||||||
|
Effect.flatMap((value) => exchange(value, redirect, pkce)),
|
||||||
|
Effect.map((tokens) => credential(browserMethodID, tokens)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
refresh: (value) => refresh(browserMethodID, value),
|
||||||
|
} satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|
||||||
|
const headless = {
|
||||||
|
integrationID: Integration.ID.make("openai"),
|
||||||
|
method: {
|
||||||
|
id: headlessMethodID,
|
||||||
|
type: "oauth",
|
||||||
|
label: "ChatGPT Pro/Plus (headless)",
|
||||||
|
},
|
||||||
|
authorize: () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
|
||||||
|
`${issuer}/api/accounts/deviceauth/usercode`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: headers("application/json"),
|
||||||
|
body: JSON.stringify({ client_id: clientID }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
|
||||||
|
return {
|
||||||
|
mode: "auto" as const,
|
||||||
|
url: `${issuer}/codex/device`,
|
||||||
|
instructions: `Enter code: ${device.user_code}`,
|
||||||
|
callback: Effect.gen(function* () {
|
||||||
|
while (true) {
|
||||||
|
const response = yield* Effect.tryPromise({
|
||||||
|
try: (signal) =>
|
||||||
|
fetch(`${issuer}/api/accounts/deviceauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers("application/json"),
|
||||||
|
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
catch: (cause) => cause,
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (yield* Effect.promise(() => response.json())) as {
|
||||||
|
authorization_code: string
|
||||||
|
code_verifier: string
|
||||||
|
}
|
||||||
|
return credential(
|
||||||
|
headlessMethodID,
|
||||||
|
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
|
||||||
|
verifier: data.code_verifier,
|
||||||
|
challenge: "",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (response.status !== 403 && response.status !== 404) {
|
||||||
|
return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
|
||||||
|
}
|
||||||
|
yield* Effect.sleep(interval + pollingSafetyMargin)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
refresh: (value) => refresh(headlessMethodID, value),
|
||||||
|
} satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|
||||||
export const OpenAIPlugin = define({
|
export const OpenAIPlugin = define({
|
||||||
id: "openai",
|
id: "openai",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const integrations = yield* Integration.Service
|
yield* ctx.integration.transform((draft) => {
|
||||||
yield* integrations.transform((draft) => {
|
|
||||||
draft.method.update(browser)
|
draft.method.update(browser)
|
||||||
draft.method.update(headless)
|
draft.method.update(headless)
|
||||||
})
|
})
|
||||||
@ -41,4 +181,112 @@ export const OpenAIPlugin = define({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
} satisfies PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>)
|
||||||
|
|
||||||
|
function headers(contentType: string) {
|
||||||
|
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function exchange(code: string, redirect: string, pkce: Pkce) {
|
||||||
|
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers("application/x-www-form-urlencoded"),
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirect,
|
||||||
|
client_id: clientID,
|
||||||
|
code_verifier: pkce.verifier,
|
||||||
|
}).toString(),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">) {
|
||||||
|
return request<TokenResponse>(`${issuer}/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers("application/x-www-form-urlencoded"),
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: value.refresh,
|
||||||
|
client_id: clientID,
|
||||||
|
}).toString(),
|
||||||
|
}).pipe(
|
||||||
|
Effect.map((tokens) => {
|
||||||
|
const next = credential(methodID, tokens)
|
||||||
|
return new Credential.OAuth({ ...next, metadata: next.metadata ?? value.metadata })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function request<A>(url: string, init: RequestInit) {
|
||||||
|
return Effect.tryPromise({
|
||||||
|
try: async (signal) => {
|
||||||
|
const response = await fetch(url, { ...init, signal })
|
||||||
|
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
|
||||||
|
return response.json() as Promise<A>
|
||||||
|
},
|
||||||
|
catch: (cause) => cause,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function credential(methodID: Integration.MethodID, tokens: TokenResponse) {
|
||||||
|
const accountID = extractAccountID(tokens)
|
||||||
|
return new Credential.OAuth({
|
||||||
|
type: "oauth",
|
||||||
|
methodID,
|
||||||
|
refresh: tokens.refresh_token,
|
||||||
|
access: tokens.access_token,
|
||||||
|
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
|
metadata: accountID ? { accountID } : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePKCE(): Promise<Pkce> {
|
||||||
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||||
|
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
|
||||||
|
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
|
||||||
|
return { verifier, challenge }
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(buffer: ArrayBuffer) {
|
||||||
|
return Buffer.from(buffer).toString("base64url")
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizeURL(redirect: string, pkce: Pkce, state: string) {
|
||||||
|
return `${issuer}/oauth/authorize?${new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: clientID,
|
||||||
|
redirect_uri: redirect,
|
||||||
|
scope: "openid profile email offline_access",
|
||||||
|
code_challenge: pkce.challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
id_token_add_organizations: "true",
|
||||||
|
codex_cli_simplified_flow: "true",
|
||||||
|
state,
|
||||||
|
originator: "opencode",
|
||||||
|
})}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAccountID(tokens: TokenResponse) {
|
||||||
|
return claim(tokens.id_token) ?? claim(tokens.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function claim(token: string) {
|
||||||
|
const part = token.split(".")[1]
|
||||||
|
if (!part) return
|
||||||
|
try {
|
||||||
|
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
|
||||||
|
return (
|
||||||
|
claims.chatgpt_account_id ??
|
||||||
|
claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
|
||||||
|
claims.organizations?.[0]?.id
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successPage =
|
||||||
|
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
|
||||||
|
const errorPage = (message: string) =>
|
||||||
|
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
|
||||||
|
|||||||
@ -1,32 +1,343 @@
|
|||||||
import { Effect } from "effect"
|
import { Duration, Effect, Schema, Stream } from "effect"
|
||||||
import { define } from "../internal"
|
import type { Scope } from "effect"
|
||||||
import { ProviderV2 } from "../../provider"
|
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||||
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
|
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||||
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
import { EventV2 } from "../../event"
|
||||||
|
import { Credential } from "../../credential"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
|
import { ModelV2 } from "../../model"
|
||||||
|
import { ModelRequest } from "../../model-request"
|
||||||
|
import { ProviderV2 } from "../../provider"
|
||||||
|
|
||||||
export const OpencodePlugin = define({
|
const defaultServer = "https://console.opencode.ai"
|
||||||
|
const clientID = "opencode-cli"
|
||||||
|
const methodID = Integration.MethodID.make("device")
|
||||||
|
const RemoteRequest = Schema.Struct({
|
||||||
|
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
|
body: Schema.Record(Schema.String, Schema.Any).pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
const RemoteModelApi = Schema.Union([
|
||||||
|
Schema.Struct({ id: ModelV2.ID.pipe(Schema.optional), ...ProviderV2.AISDK.fields }),
|
||||||
|
Schema.Struct({ id: ModelV2.ID.pipe(Schema.optional), ...ProviderV2.Native.fields }),
|
||||||
|
Schema.Struct({ id: ModelV2.ID }),
|
||||||
|
])
|
||||||
|
const RemoteCost = Schema.Struct({
|
||||||
|
tier: Schema.Struct({ type: Schema.Literal("context"), size: Schema.Int }).pipe(Schema.optional),
|
||||||
|
input: Schema.Finite,
|
||||||
|
output: Schema.Finite,
|
||||||
|
cache: Schema.Struct({
|
||||||
|
read: Schema.Finite.pipe(Schema.optional),
|
||||||
|
write: Schema.Finite.pipe(Schema.optional),
|
||||||
|
}).pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
const RemoteModel = Schema.Struct({
|
||||||
|
family: ModelV2.Family.pipe(Schema.optional),
|
||||||
|
name: Schema.String.pipe(Schema.optional),
|
||||||
|
api: RemoteModelApi.pipe(Schema.optional),
|
||||||
|
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
|
||||||
|
request: Schema.Struct({ ...RemoteRequest.fields, variant: Schema.String.pipe(Schema.optional) }).pipe(
|
||||||
|
Schema.optional,
|
||||||
|
),
|
||||||
|
variants: Schema.Struct({
|
||||||
|
id: ModelV2.VariantID,
|
||||||
|
...RemoteRequest.fields,
|
||||||
|
}).pipe(Schema.Array, Schema.optional),
|
||||||
|
cost: Schema.Union([RemoteCost, Schema.Array(RemoteCost)]).pipe(Schema.optional),
|
||||||
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
|
limit: Schema.Struct({
|
||||||
|
context: Schema.Int.pipe(Schema.optional),
|
||||||
|
input: Schema.Int.pipe(Schema.optional),
|
||||||
|
output: Schema.Int.pipe(Schema.optional),
|
||||||
|
}).pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
const RemoteProvider = Schema.Struct({
|
||||||
|
name: Schema.String.pipe(Schema.optional),
|
||||||
|
api: ProviderV2.Api.pipe(Schema.optional),
|
||||||
|
request: RemoteRequest.pipe(Schema.optional),
|
||||||
|
models: Schema.Record(Schema.String, RemoteModel).pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
const RemoteConfig = Schema.Struct({
|
||||||
|
providers: Schema.Record(Schema.String, RemoteProvider),
|
||||||
|
})
|
||||||
|
const RemoteResponse = Schema.Struct({ config: RemoteConfig })
|
||||||
|
const Device = Schema.Struct({
|
||||||
|
device_code: Schema.String,
|
||||||
|
user_code: Schema.String,
|
||||||
|
verification_uri_complete: Schema.String,
|
||||||
|
expires_in: Schema.Number,
|
||||||
|
interval: Schema.Number,
|
||||||
|
})
|
||||||
|
const Token = Schema.Struct({
|
||||||
|
access_token: Schema.String,
|
||||||
|
refresh_token: Schema.String,
|
||||||
|
expires_in: Schema.Number,
|
||||||
|
})
|
||||||
|
const TokenPending = Schema.Struct({ error: Schema.String })
|
||||||
|
const DeviceToken = Schema.Union([Token, TokenPending])
|
||||||
|
const User = Schema.Struct({ id: Schema.String, email: Schema.String })
|
||||||
|
const Org = Schema.Struct({ id: Schema.String, name: Schema.String })
|
||||||
|
|
||||||
|
function oauth(http: HttpClient.HttpClient) {
|
||||||
|
return {
|
||||||
|
integrationID: Integration.ID.make("opencode"),
|
||||||
|
method: {
|
||||||
|
id: methodID,
|
||||||
|
type: "oauth",
|
||||||
|
label: "Sign in with OpenCode",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
key: "server",
|
||||||
|
message: "OpenCode server",
|
||||||
|
placeholder: defaultServer,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
authorize: (inputs) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const url = new URL(inputs.server || defaultServer)
|
||||||
|
const server = `${url.origin}${url.pathname.replace(/\/+$/, "")}`
|
||||||
|
const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device)
|
||||||
|
return {
|
||||||
|
mode: "auto" as const,
|
||||||
|
url: `${server}${device.verification_uri_complete}`,
|
||||||
|
instructions: `Enter code: ${device.user_code}`,
|
||||||
|
callback: poll(http, server, device.device_code, Duration.seconds(device.interval)),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
refresh: (credential) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = typeof credential.metadata?.server === "string" ? credential.metadata.server : defaultServer
|
||||||
|
const token = yield* post(
|
||||||
|
http,
|
||||||
|
`${server}/auth/device/token`,
|
||||||
|
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
|
||||||
|
Token,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...credential,
|
||||||
|
access: token.access_token,
|
||||||
|
refresh: token.refresh_token,
|
||||||
|
expires: Date.now() + token.expires_in * 1000,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
label: (credential) => {
|
||||||
|
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
|
||||||
|
},
|
||||||
|
} satisfies IntegrationOAuthMethodRegistration
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
|
||||||
id: "opencode",
|
id: "opencode",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const integrations = yield* Integration.Service
|
const events = yield* EventV2.Service
|
||||||
let hasKey = false
|
const http = yield* HttpClient.HttpClient
|
||||||
yield* ctx.catalog.transform(
|
let connected = false
|
||||||
Effect.fn(function* (evt) {
|
let providers: typeof RemoteConfig.Type.providers | undefined
|
||||||
const item = evt.provider.get(ProviderV2.ID.opencode)
|
|
||||||
if (!item) return
|
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||||
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
|
const connection = yield* ctx.integration.connection.active("opencode")
|
||||||
hasKey = Boolean(
|
const credential = connection
|
||||||
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
|
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
: undefined
|
||||||
|
connected = connection !== undefined
|
||||||
|
providers = credential
|
||||||
|
? yield* fetchProviders(http, credential).pipe(
|
||||||
|
Effect.catch((cause) =>
|
||||||
|
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
evt.provider.update(item.provider.id, (provider) => {
|
: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* ctx.integration.transform((draft) => {
|
||||||
|
draft.update("opencode", (integration) => {
|
||||||
|
integration.name = "OpenCode"
|
||||||
|
})
|
||||||
|
draft.method.update(oauth(http))
|
||||||
|
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* load()
|
||||||
|
yield* ctx.catalog.transform((catalog) => {
|
||||||
|
for (const [providerID, item] of Object.entries(providers ?? {})) {
|
||||||
|
catalog.provider.update(providerID, (provider) => {
|
||||||
|
if (item.name !== undefined) provider.name = item.name
|
||||||
|
if (item.api !== undefined) provider.api = { ...item.api }
|
||||||
|
if (item.request !== undefined) {
|
||||||
|
Object.assign(provider.request.headers, item.request.headers)
|
||||||
|
Object.assign(provider.request.body, item.request.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const providerApi = catalog.provider.get(providerID)?.provider.api
|
||||||
|
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
|
||||||
|
|
||||||
|
for (const [modelID, config] of Object.entries(item.models ?? {})) {
|
||||||
|
catalog.model.update(providerID, modelID, (model) => {
|
||||||
|
if (config.family !== undefined) model.family = config.family
|
||||||
|
if (config.name !== undefined) model.name = config.name
|
||||||
|
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||||
|
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
|
||||||
|
if (config.capabilities !== undefined) {
|
||||||
|
model.capabilities = {
|
||||||
|
tools: config.capabilities.tools,
|
||||||
|
input: [...config.capabilities.input],
|
||||||
|
output: [...config.capabilities.output],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.request !== undefined) {
|
||||||
|
ModelRequest.assign(model.request, {
|
||||||
|
headers: config.request.headers,
|
||||||
|
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
|
||||||
|
})
|
||||||
|
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||||
|
}
|
||||||
|
if (config.variants !== undefined) {
|
||||||
|
for (const variant of config.variants) {
|
||||||
|
let existing = model.variants.find((item) => item.id === variant.id)
|
||||||
|
if (!existing) {
|
||||||
|
existing = { id: variant.id, headers: {}, body: {}, generation: {}, options: {} }
|
||||||
|
model.variants.push(existing)
|
||||||
|
}
|
||||||
|
ModelRequest.assign(existing, {
|
||||||
|
headers: variant.headers,
|
||||||
|
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.cost !== undefined) {
|
||||||
|
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||||
|
tier: cost.tier && { ...cost.tier },
|
||||||
|
input: cost.input,
|
||||||
|
output: cost.output,
|
||||||
|
cache: { read: cost.cache?.read ?? 0, write: cost.cache?.write ?? 0 },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if (config.disabled !== undefined) model.enabled = !config.disabled
|
||||||
|
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = catalog.provider.get(ProviderV2.ID.opencode)
|
||||||
|
if (!item) return
|
||||||
|
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.request.body.apiKey)
|
||||||
|
catalog.provider.update(item.provider.id, (provider) => {
|
||||||
if (!hasKey) provider.request.body.apiKey = "public"
|
if (!hasKey) provider.request.body.apiKey = "public"
|
||||||
})
|
})
|
||||||
if (hasKey) return
|
if (hasKey) return
|
||||||
for (const model of item.models.values()) {
|
for (const model of item.models.values()) {
|
||||||
if (!model.cost.some((cost) => cost.input > 0)) continue
|
if (!model.cost.some((cost) => cost.input > 0)) continue
|
||||||
evt.model.update(item.provider.id, model.id, (draft) => {
|
catalog.model.update(item.provider.id, model.id, (draft) => {
|
||||||
draft.enabled = false
|
draft.enabled = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
|
|
||||||
|
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
|
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||||
|
Stream.runForEach(() => load().pipe(Effect.andThen(ctx.catalog.reload()))),
|
||||||
|
Effect.forkScoped({ startImmediately: true }),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function fetchProviders(http: HttpClient.HttpClient, value: CredentialValue) {
|
||||||
|
const metadata = value.metadata
|
||||||
|
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
|
||||||
|
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
|
||||||
|
const token = value.type === "oauth" ? value.access : value.key
|
||||||
|
return http
|
||||||
|
.execute(
|
||||||
|
HttpClientRequest.get(`${server}/api/config`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.bearerToken(token),
|
||||||
|
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
Effect.flatMap((response) => {
|
||||||
|
if (response.status === 404) return Effect.succeed(undefined)
|
||||||
|
return HttpClientResponse.filterStatusOk(response).pipe(
|
||||||
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
|
||||||
|
Effect.map((remote) => remote.config.providers),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
|
||||||
|
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.sleep(wait)
|
||||||
|
const result = yield* post(
|
||||||
|
http,
|
||||||
|
`${server}/auth/device/token`,
|
||||||
|
{
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
device_code: deviceCode,
|
||||||
|
client_id: clientID,
|
||||||
|
},
|
||||||
|
DeviceToken,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
if ("access_token" in result) return yield* credential(http, server, result)
|
||||||
|
if (result.error === "authorization_pending") return yield* loop(wait)
|
||||||
|
if (result.error === "slow_down") {
|
||||||
|
return yield* loop(Duration.sum(wait, Duration.seconds(5)))
|
||||||
|
}
|
||||||
|
return yield* Effect.fail(new Error(`Device authorization failed: ${result.error}`))
|
||||||
|
})
|
||||||
|
return loop(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
function credential(http: HttpClient.HttpClient, server: string, token: typeof Token.Type) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const [user, orgs] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
get(http, `${server}/api/user`, token.access_token, User),
|
||||||
|
get(http, `${server}/api/orgs`, token.access_token, Schema.Array(Org)),
|
||||||
|
],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const org = orgs.toSorted((a, b) => a.id.localeCompare(b.id))[0]
|
||||||
|
return new Credential.OAuth({
|
||||||
|
type: "oauth" as const,
|
||||||
|
methodID,
|
||||||
|
access: token.access_token,
|
||||||
|
refresh: token.refresh_token,
|
||||||
|
expires: Date.now() + token.expires_in * 1000,
|
||||||
|
metadata: {
|
||||||
|
server,
|
||||||
|
accountID: user.id,
|
||||||
|
email: user.email,
|
||||||
|
orgID: org?.id,
|
||||||
|
orgName: org?.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function get<S extends Schema.Top>(http: HttpClient.HttpClient, url: string, token: string, schema: S) {
|
||||||
|
return HttpClient.filterStatusOk(http)
|
||||||
|
.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(token)))
|
||||||
|
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function post<S extends Schema.Top>(
|
||||||
|
http: HttpClient.HttpClient,
|
||||||
|
url: string,
|
||||||
|
body: Record<string, string>,
|
||||||
|
schema: S,
|
||||||
|
statusOk = true,
|
||||||
|
) {
|
||||||
|
return HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.String))(body),
|
||||||
|
Effect.flatMap((request) => http.execute(request)),
|
||||||
|
Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))),
|
||||||
|
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -210,7 +210,7 @@ export const locationLayer = Layer.effect(
|
|||||||
modelID: session.model.id,
|
modelID: session.model.id,
|
||||||
})
|
})
|
||||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||||
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
const connection = yield* integrations.connection.active(Integration.ID.make(selected.providerID))
|
||||||
return yield* resolve(
|
return yield* resolve(
|
||||||
session,
|
session,
|
||||||
selected,
|
selected,
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export class EmbeddedSource extends Schema.Class<EmbeddedSource>("SkillV2.Embedd
|
|||||||
|
|
||||||
export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe(
|
export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe(
|
||||||
Schema.toTaggedUnion("type"),
|
Schema.toTaggedUnion("type"),
|
||||||
|
Schema.annotate({ identifier: "SkillV2.Source" }),
|
||||||
withStatics(() => ({
|
withStatics(() => ({
|
||||||
equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => {
|
equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => {
|
||||||
if (a.type !== b.type) return false
|
if (a.type !== b.type) return false
|
||||||
|
|||||||
@ -332,7 +332,7 @@ describe("Integration", () => {
|
|||||||
},
|
},
|
||||||
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
|
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
|
||||||
])
|
])
|
||||||
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
|
expect(yield* integrations.connection.active(integrationID)).toEqual({
|
||||||
type: "credential",
|
type: "credential",
|
||||||
id: personal.id,
|
id: personal.id,
|
||||||
label: "Personal",
|
label: "Personal",
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
|||||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { tempLocationLayer } from "../fixture/location"
|
import { tempLocationLayer } from "../fixture/location"
|
||||||
|
|
||||||
export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe(
|
export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe(
|
||||||
@ -16,6 +17,7 @@ export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2
|
|||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
Credential.defaultLayer,
|
Credential.defaultLayer,
|
||||||
EventV2.defaultLayer,
|
EventV2.defaultLayer,
|
||||||
|
FetchHttpClient.layer,
|
||||||
FSUtil.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
Global.defaultLayer,
|
Global.defaultLayer,
|
||||||
Layer.succeed(
|
Layer.succeed(
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
@ -31,6 +32,10 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||||||
integration: overrides.integration ?? {
|
integration: overrides.integration ?? {
|
||||||
transform: () => Effect.die("unused integration.transform"),
|
transform: () => Effect.die("unused integration.transform"),
|
||||||
reload: () => Effect.die("unused integration.reload"),
|
reload: () => Effect.die("unused integration.reload"),
|
||||||
|
connection: {
|
||||||
|
active: () => Effect.die("unused integration.connection.active"),
|
||||||
|
resolve: () => Effect.die("unused integration.connection.resolve"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugin: overrides.plugin ?? {
|
plugin: overrides.plugin ?? {
|
||||||
add: () => Effect.die("unused plugin.add"),
|
add: () => Effect.die("unused plugin.add"),
|
||||||
@ -138,6 +143,13 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
|
|||||||
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
|
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
|
||||||
return {
|
return {
|
||||||
reload: integration.reload,
|
reload: integration.reload,
|
||||||
|
connection: {
|
||||||
|
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
||||||
|
resolve: (connection) =>
|
||||||
|
integration.connection.resolve(
|
||||||
|
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
|
||||||
|
),
|
||||||
|
},
|
||||||
transform: (callback) =>
|
transform: (callback) =>
|
||||||
integration.transform((draft) =>
|
integration.transform((draft) =>
|
||||||
callback({
|
callback({
|
||||||
@ -150,16 +162,75 @@ export function integrationHost(integration: Integration.Interface): PluginConte
|
|||||||
remove: (id) => draft.remove(Integration.ID.make(id)),
|
remove: (id) => draft.remove(Integration.ID.make(id)),
|
||||||
method: {
|
method: {
|
||||||
list: (id) => draft.method.list(Integration.ID.make(id)).map(method),
|
list: (id) => draft.method.list(Integration.ID.make(id)).map(method),
|
||||||
update: (input) =>
|
update: (input) => {
|
||||||
input.method.type === "env"
|
if ("authorize" in input) {
|
||||||
? draft.method.update({
|
const methodID = Integration.MethodID.make(input.method.id)
|
||||||
|
const refresh = input.refresh
|
||||||
|
draft.method.update({
|
||||||
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
|
method: { ...input.method, id: methodID },
|
||||||
|
authorize: (inputs) =>
|
||||||
|
input.authorize(inputs).pipe(
|
||||||
|
Effect.map((authorization) => {
|
||||||
|
if (authorization.mode === "auto") {
|
||||||
|
return {
|
||||||
|
...authorization,
|
||||||
|
callback: authorization.callback.pipe(
|
||||||
|
Effect.map(
|
||||||
|
(credential) =>
|
||||||
|
new Credential.OAuth({
|
||||||
|
...credential,
|
||||||
|
methodID: Integration.MethodID.make(credential.methodID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...authorization,
|
||||||
|
callback: (code: string) =>
|
||||||
|
authorization.callback(code).pipe(
|
||||||
|
Effect.map(
|
||||||
|
(credential) =>
|
||||||
|
new Credential.OAuth({
|
||||||
|
...credential,
|
||||||
|
methodID: Integration.MethodID.make(credential.methodID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...(refresh
|
||||||
|
? {
|
||||||
|
refresh: (value: Credential.OAuth) =>
|
||||||
|
refresh(value).pipe(
|
||||||
|
Effect.map(
|
||||||
|
(next) =>
|
||||||
|
new Credential.OAuth({
|
||||||
|
...next,
|
||||||
|
methodID: Integration.MethodID.make(next.methodID),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(input.label ? { label: input.label } : {}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (input.method.type === "env") {
|
||||||
|
draft.method.update({
|
||||||
integrationID: Integration.ID.make(input.integrationID),
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
method: { ...input.method, names: [...input.method.names] },
|
method: { ...input.method, names: [...input.method.names] },
|
||||||
})
|
})
|
||||||
: draft.method.update({
|
return
|
||||||
|
}
|
||||||
|
draft.method.update({
|
||||||
integrationID: Integration.ID.make(input.integrationID),
|
integrationID: Integration.ID.make(input.integrationID),
|
||||||
method: input.method,
|
method: input.method,
|
||||||
}),
|
})
|
||||||
|
},
|
||||||
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
|
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
@ -48,6 +49,79 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
|||||||
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
|
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
|
||||||
|
|
||||||
describe("OpencodePlugin", () => {
|
describe("OpencodePlugin", () => {
|
||||||
|
it.effect("registers OAuth and API key methods", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* addPlugin()
|
||||||
|
expect((yield* (yield* Integration.Service).get(Integration.ID.make("opencode")))?.methods).toEqual([
|
||||||
|
{
|
||||||
|
id: Integration.MethodID.make("device"),
|
||||||
|
type: "oauth",
|
||||||
|
label: "Sign in with OpenCode",
|
||||||
|
prompts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
key: "server",
|
||||||
|
message: "OpenCode server",
|
||||||
|
placeholder: "https://console.opencode.ai",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "key", label: "API key" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("loads providers and models from the connected OpenCode server", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const authorization: Array<string | null> = []
|
||||||
|
return {
|
||||||
|
authorization,
|
||||||
|
server: Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch: (request) => {
|
||||||
|
authorization.push(request.headers.get("authorization"))
|
||||||
|
return Response.json({
|
||||||
|
config: {
|
||||||
|
providers: {
|
||||||
|
remote: {
|
||||||
|
name: "Remote",
|
||||||
|
models: {
|
||||||
|
model: { name: "Remote Model" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
({ authorization, server }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
|
yield* credentials.create({
|
||||||
|
integrationID: Integration.ID.make("opencode"),
|
||||||
|
value: new Credential.Key({
|
||||||
|
type: "key",
|
||||||
|
key: "secret",
|
||||||
|
metadata: { server: server.url.origin },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
expect((yield* catalog.provider.get(ProviderV2.ID.make("remote")))?.name).toBe("Remote")
|
||||||
|
expect((yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))?.name).toBe(
|
||||||
|
"Remote Model",
|
||||||
|
)
|
||||||
|
expect(authorization).toContain("Bearer secret")
|
||||||
|
}),
|
||||||
|
({ server }) => Effect.promise(() => server.stop(true)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@ -13,12 +13,11 @@ import { Env } from "../env"
|
|||||||
import { applyEdits, modify } from "jsonc-parser"
|
import { applyEdits, modify } from "jsonc-parser"
|
||||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import { Account } from "@/account/account"
|
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state"
|
import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
import { Context, Duration, Effect, Exit, Fiber, Layer, Schema } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import { containsPath, type InstanceContext } from "../project/instance-context"
|
import { containsPath, type InstanceContext } from "../project/instance-context"
|
||||||
@ -177,7 +176,6 @@ export const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const authSvc = yield* Auth.Service
|
const authSvc = yield* Auth.Service
|
||||||
const accountSvc = yield* Account.Service
|
|
||||||
const env = yield* Env.Service
|
const env = yield* Env.Service
|
||||||
const npmSvc = yield* Npm.Service
|
const npmSvc = yield* Npm.Service
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
@ -316,7 +314,6 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
let result: Info = {}
|
let result: Info = {}
|
||||||
const authEnv: Record<string, string> = {}
|
const authEnv: Record<string, string> = {}
|
||||||
const consoleManagedProviders = new Set<string>()
|
|
||||||
let activeOrgName: string | undefined
|
let activeOrgName: string | undefined
|
||||||
|
|
||||||
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
|
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
|
||||||
@ -474,44 +471,6 @@ export const layer = Layer.effect(
|
|||||||
yield* Effect.logDebug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
yield* Effect.logDebug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeAccount = Option.getOrUndefined(
|
|
||||||
yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
|
|
||||||
)
|
|
||||||
if (activeAccount?.active_org_id) {
|
|
||||||
const accountID = activeAccount.id
|
|
||||||
const orgID = activeAccount.active_org_id
|
|
||||||
const url = activeAccount.url
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const [configOpt, tokenOpt] = yield* Effect.all(
|
|
||||||
[accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
if (Option.isSome(tokenOpt)) {
|
|
||||||
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
|
|
||||||
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Option.isSome(configOpt)) {
|
|
||||||
const source = `${url}/api/config`
|
|
||||||
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
|
|
||||||
dir: path.dirname(source),
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
for (const providerID of Object.keys(next.provider ?? {})) {
|
|
||||||
consoleManagedProviders.add(providerID)
|
|
||||||
}
|
|
||||||
yield* merge(source, next, "global")
|
|
||||||
}
|
|
||||||
}).pipe(
|
|
||||||
Effect.withSpan("Config.loadActiveOrgConfig"),
|
|
||||||
Effect.catch((err) =>
|
|
||||||
Effect.logDebug("failed to fetch remote account config", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const managedDir = ConfigManaged.managedConfigDir()
|
const managedDir = ConfigManaged.managedConfigDir()
|
||||||
if (existsSync(managedDir)) {
|
if (existsSync(managedDir)) {
|
||||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||||
@ -587,7 +546,7 @@ export const layer = Layer.effect(
|
|||||||
directories,
|
directories,
|
||||||
deps,
|
deps,
|
||||||
consoleState: {
|
consoleState: {
|
||||||
consoleManagedProviders: Array.from(consoleManagedProviders),
|
consoleManagedProviders: [],
|
||||||
activeOrgName,
|
activeOrgName,
|
||||||
switchableOrgCount: 0,
|
switchableOrgCount: 0,
|
||||||
},
|
},
|
||||||
@ -676,11 +635,10 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(FSUtil.defaultLayer),
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
Layer.provide(Env.defaultLayer),
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Account.defaultLayer),
|
|
||||||
Layer.provide(Npm.defaultLayer),
|
Layer.provide(Npm.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient])
|
export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Env.node, Npm.node, httpClient])
|
||||||
|
|
||||||
export * as Config from "./config"
|
export * as Config from "./config"
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApi } from "effect/unstable/httpapi"
|
import { HttpApi } from "effect/unstable/httpapi"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
|
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||||
import { InstanceDisposed } from "@/server/event"
|
import { InstanceDisposed } from "@/server/event"
|
||||||
import { Question } from "@/question"
|
import { Question } from "@/question"
|
||||||
import { ConfigApi } from "./groups/config"
|
import { ConfigApi } from "./groups/config"
|
||||||
@ -72,7 +75,16 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
|
|||||||
.addHttpApi(InstanceHttpApi)
|
.addHttpApi(InstanceHttpApi)
|
||||||
.addHttpApi(Api)
|
.addHttpApi(Api)
|
||||||
.addHttpApi(PtyConnectApi)
|
.addHttpApi(PtyConnectApi)
|
||||||
.annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected])
|
.annotate(HttpApi.AdditionalSchemas, [
|
||||||
|
EventSchema,
|
||||||
|
Question.Replied,
|
||||||
|
Question.Rejected,
|
||||||
|
Credential.Value,
|
||||||
|
Integration.Inputs,
|
||||||
|
Integration.Method,
|
||||||
|
Integration.Ref,
|
||||||
|
SkillV2.Source,
|
||||||
|
])
|
||||||
|
|
||||||
export type RootHttpApiType = typeof RootHttpApi
|
export type RootHttpApiType = typeof RootHttpApi
|
||||||
export type InstanceHttpApiType = typeof InstanceHttpApi
|
export type InstanceHttpApiType = typeof InstanceHttpApi
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
|
import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
|
||||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||||
import { Cause, Effect, Exit, Layer, Option } from "effect"
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
@ -13,7 +13,6 @@ import { InstanceRef } from "../../src/effect/instance-ref"
|
|||||||
import type { InstanceContext } from "../../src/project/instance-context"
|
import type { InstanceContext } from "../../src/project/instance-context"
|
||||||
import { Auth } from "../../src/auth"
|
import { Auth } from "../../src/auth"
|
||||||
import { Account } from "../../src/account/account"
|
import { Account } from "../../src/account/account"
|
||||||
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Env } from "../../src/env"
|
import { Env } from "../../src/env"
|
||||||
import {
|
import {
|
||||||
@ -141,7 +140,6 @@ const clear = (wait = false) => Effect.runPromise(clearEffect(wait))
|
|||||||
// Get managed config directory from environment (set in preload.ts)
|
// Get managed config directory from environment (set in preload.ts)
|
||||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||||
const originalTestToken = process.env.TEST_TOKEN
|
const originalTestToken = process.env.TEST_TOKEN
|
||||||
const originalConsoleToken = process.env.OPENCODE_CONSOLE_TOKEN
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await clear(true)
|
await clear(true)
|
||||||
@ -151,8 +149,6 @@ afterEach(async () => {
|
|||||||
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
||||||
if (originalTestToken === undefined) delete process.env.TEST_TOKEN
|
if (originalTestToken === undefined) delete process.env.TEST_TOKEN
|
||||||
else process.env.TEST_TOKEN = originalTestToken
|
else process.env.TEST_TOKEN = originalTestToken
|
||||||
if (originalConsoleToken === undefined) delete process.env.OPENCODE_CONSOLE_TOKEN
|
|
||||||
else process.env.OPENCODE_CONSOLE_TOKEN = originalConsoleToken
|
|
||||||
await clear(true)
|
await clear(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -564,49 +560,6 @@ it.instance("handles file inclusion with replacement tokens", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const accountTokenIt = configIt({
|
|
||||||
account: Layer.mock(Account.Service)({
|
|
||||||
active: () =>
|
|
||||||
Effect.succeed(
|
|
||||||
Option.some({
|
|
||||||
id: AccountID.make("account-1"),
|
|
||||||
email: "user@example.com",
|
|
||||||
url: "https://control.example.com",
|
|
||||||
active_org_id: OrgID.make("org-1"),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
activeOrg: () =>
|
|
||||||
Effect.succeed(
|
|
||||||
Option.some({
|
|
||||||
account: {
|
|
||||||
id: AccountID.make("account-1"),
|
|
||||||
email: "user@example.com",
|
|
||||||
url: "https://control.example.com",
|
|
||||||
active_org_id: OrgID.make("org-1"),
|
|
||||||
},
|
|
||||||
org: {
|
|
||||||
id: OrgID.make("org-1"),
|
|
||||||
name: "Example Org",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
config: () =>
|
|
||||||
Effect.succeed(
|
|
||||||
Option.some({
|
|
||||||
provider: { opencode: { options: { apiKey: "{env:OPENCODE_CONSOLE_TOKEN}" } } },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
accountTokenIt.instance("resolves env templates in account config with account token", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const config = yield* Config.use.get()
|
|
||||||
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.instance("validates config schema and throws on invalid fields", () =>
|
it.instance("validates config schema and throws on invalid fields", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestInstance
|
const test = yield* TestInstance
|
||||||
|
|||||||
@ -68,6 +68,20 @@ function isBuiltInEndpointError(name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("PublicApi OpenAPI v2 errors", () => {
|
describe("PublicApi OpenAPI v2 errors", () => {
|
||||||
|
test("includes plugin-facing core schemas", () => {
|
||||||
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||||
|
|
||||||
|
expect(Object.keys(spec.components.schemas)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"CredentialValue",
|
||||||
|
"IntegrationInputs",
|
||||||
|
"IntegrationMethod",
|
||||||
|
"IntegrationRef",
|
||||||
|
"SkillV2Source",
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("documents nested legacy global sync events", () => {
|
test("documents nested legacy global sync events", () => {
|
||||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||||
const schema = spec.components.schemas.SyncEventSessionCreated
|
const schema = spec.components.schemas.SyncEventSessionCreated
|
||||||
|
|||||||
@ -13,6 +13,8 @@
|
|||||||
"./tool": "./src/tool.ts",
|
"./tool": "./src/tool.ts",
|
||||||
"./tui": "./src/tui.ts",
|
"./tui": "./src/tui.ts",
|
||||||
"./v2/effect": "./src/v2/effect/index.ts",
|
"./v2/effect": "./src/v2/effect/index.ts",
|
||||||
|
"./v2/effect/integration": "./src/v2/effect/integration.ts",
|
||||||
|
"./v2/effect/plugin": "./src/v2/effect/plugin.ts",
|
||||||
"./v2/promise": "./src/v2/promise/index.ts"
|
"./v2/promise": "./src/v2/promise/index.ts"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -1,13 +1,41 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ConnectionInfo,
|
||||||
|
CredentialOAuth,
|
||||||
|
CredentialValue,
|
||||||
IntegrationEnvMethod,
|
IntegrationEnvMethod,
|
||||||
IntegrationInfo,
|
IntegrationInputs,
|
||||||
IntegrationKeyMethod,
|
IntegrationKeyMethod,
|
||||||
|
IntegrationMethod,
|
||||||
IntegrationOAuthMethod,
|
IntegrationOAuthMethod,
|
||||||
|
IntegrationRef,
|
||||||
} from "@opencode-ai/sdk/v2/types"
|
} from "@opencode-ai/sdk/v2/types"
|
||||||
|
import type { Effect, Scope } from "effect"
|
||||||
import type { Hooks } from "./registration.js"
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod
|
export type IntegrationOAuthAuthorization = {
|
||||||
|
readonly url: string
|
||||||
|
readonly instructions: string
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
readonly mode: "auto"
|
||||||
|
readonly callback: Effect.Effect<CredentialOAuth, unknown>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly mode: "code"
|
||||||
|
readonly callback: (code: string) => Effect.Effect<CredentialOAuth, unknown>
|
||||||
|
}
|
||||||
|
)
|
||||||
|
export type IntegrationOAuthMethodRegistration = {
|
||||||
|
readonly integrationID: string
|
||||||
|
readonly method: IntegrationOAuthMethod
|
||||||
|
readonly authorize: (
|
||||||
|
inputs: IntegrationInputs,
|
||||||
|
) => Effect.Effect<IntegrationOAuthAuthorization, unknown, Scope.Scope>
|
||||||
|
readonly refresh?: (credential: CredentialOAuth) => Effect.Effect<CredentialOAuth, unknown>
|
||||||
|
readonly label?: (credential: CredentialOAuth) => string | undefined
|
||||||
|
}
|
||||||
export type IntegrationMethodRegistration =
|
export type IntegrationMethodRegistration =
|
||||||
|
| IntegrationOAuthMethodRegistration
|
||||||
| {
|
| {
|
||||||
readonly integrationID: string
|
readonly integrationID: string
|
||||||
readonly method: IntegrationKeyMethod
|
readonly method: IntegrationKeyMethod
|
||||||
@ -18,9 +46,9 @@ export type IntegrationMethodRegistration =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IntegrationDraft {
|
export interface IntegrationDraft {
|
||||||
list(): readonly Pick<IntegrationInfo, "id" | "name">[]
|
list(): readonly IntegrationRef[]
|
||||||
get(id: string): Pick<IntegrationInfo, "id" | "name"> | undefined
|
get(id: string): IntegrationRef | undefined
|
||||||
update(id: string, update: (integration: Pick<IntegrationInfo, "id" | "name">) => void): void
|
update(id: string, update: (integration: IntegrationRef) => void): void
|
||||||
remove(id: string): void
|
remove(id: string): void
|
||||||
readonly method: {
|
readonly method: {
|
||||||
list(integrationID: string): readonly IntegrationMethod[]
|
list(integrationID: string): readonly IntegrationMethod[]
|
||||||
@ -29,6 +57,9 @@ export interface IntegrationDraft {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IntegrationHooks = Hooks<{
|
export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> {
|
||||||
transform: IntegrationDraft
|
readonly connection: {
|
||||||
}>
|
readonly active: (integrationID: string) => Effect.Effect<ConnectionInfo | undefined>
|
||||||
|
readonly resolve: (connection: ConnectionInfo) => Effect.Effect<CredentialValue | undefined, unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import type { Effect, Scope } from "effect"
|
import type { Effect, Scope } from "effect"
|
||||||
import type { PluginContext } from "./context.js"
|
import type { PluginContext } from "./context.js"
|
||||||
|
|
||||||
export interface Plugin {
|
export interface Plugin<R = Scope.Scope> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly effect: (context: PluginContext) => Effect.Effect<void, never, Scope.Scope>
|
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function define(plugin: Plugin) {
|
export function define<R = Scope.Scope>(plugin: Plugin<R>) {
|
||||||
return plugin
|
return plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,9 @@
|
|||||||
import type { SkillV2Info } from "@opencode-ai/sdk/v2/types"
|
import type { SkillV2Source } from "@opencode-ai/sdk/v2/types"
|
||||||
import type { Hooks } from "./registration.js"
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
export type SkillSource =
|
|
||||||
| { readonly type: "directory"; readonly path: string }
|
|
||||||
| { readonly type: "url"; readonly url: string }
|
|
||||||
| { readonly type: "embedded"; readonly skill: SkillV2Info }
|
|
||||||
|
|
||||||
export interface SkillDraft {
|
export interface SkillDraft {
|
||||||
source(source: SkillSource): void
|
source(source: SkillV2Source): void
|
||||||
list(): readonly SkillSource[]
|
list(): readonly SkillV2Source[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SkillHooks = Hooks<{
|
export type SkillHooks = Hooks<{
|
||||||
|
|||||||
@ -10,8 +10,7 @@ export type { CommandDraft, CommandHooks } from "./command.js"
|
|||||||
export type {
|
export type {
|
||||||
IntegrationDraft,
|
IntegrationDraft,
|
||||||
IntegrationHooks,
|
IntegrationHooks,
|
||||||
IntegrationMethod,
|
|
||||||
IntegrationMethodRegistration,
|
IntegrationMethodRegistration,
|
||||||
} from "./integration.js"
|
} from "./integration.js"
|
||||||
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
|
||||||
export type { SkillDraft, SkillHooks, SkillSource } from "./skill.js"
|
export type { SkillDraft, SkillHooks } from "./skill.js"
|
||||||
|
|||||||
@ -1,8 +1,19 @@
|
|||||||
import type { IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "../effect/integration.js"
|
import type {
|
||||||
|
IntegrationDraft,
|
||||||
|
IntegrationMethodRegistration,
|
||||||
|
} from "../effect/integration.js"
|
||||||
|
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
|
||||||
import type { Hooks } from "./registration.js"
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
export type { IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration }
|
export type { IntegrationDraft, IntegrationMethodRegistration }
|
||||||
|
|
||||||
export type IntegrationHooks = Hooks<{
|
export interface IntegrationHooks extends Hooks<{ transform: IntegrationDraft }> {
|
||||||
transform: IntegrationDraft
|
readonly connection: {
|
||||||
}>
|
readonly active: (
|
||||||
|
integrationID: string,
|
||||||
|
) => Promise<import("@opencode-ai/sdk/v2/types").ConnectionInfo | undefined>
|
||||||
|
readonly resolve: (
|
||||||
|
connection: import("@opencode-ai/sdk/v2/types").ConnectionInfo,
|
||||||
|
) => Promise<CredentialValue | undefined>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { SkillDraft, SkillSource } from "../effect/skill.js"
|
import type { SkillDraft } from "../effect/skill.js"
|
||||||
import type { Hooks } from "./registration.js"
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
export type { SkillDraft, SkillSource }
|
export type { SkillDraft }
|
||||||
|
|
||||||
export type SkillHooks = Hooks<{
|
export type SkillHooks = Hooks<{
|
||||||
transform: SkillDraft
|
transform: SkillDraft
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export type ClientOptions = {
|
|||||||
export type Event =
|
export type Event =
|
||||||
| EventModelsDevRefreshed
|
| EventModelsDevRefreshed
|
||||||
| EventIntegrationUpdated
|
| EventIntegrationUpdated
|
||||||
|
| EventIntegrationConnectionUpdated
|
||||||
| EventCatalogUpdated
|
| EventCatalogUpdated
|
||||||
| EventSessionCreated
|
| EventSessionCreated
|
||||||
| EventSessionUpdated
|
| EventSessionUpdated
|
||||||
@ -742,6 +743,13 @@ export type GlobalEvent = {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
type: "integration.connection.updated"
|
||||||
|
properties: {
|
||||||
|
integrationID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "catalog.updated"
|
type: "catalog.updated"
|
||||||
@ -2744,6 +2752,7 @@ export type ProviderNotFoundError = {
|
|||||||
export type V2Event =
|
export type V2Event =
|
||||||
| V2EventModelsDevRefreshed
|
| V2EventModelsDevRefreshed
|
||||||
| V2EventIntegrationUpdated
|
| V2EventIntegrationUpdated
|
||||||
|
| V2EventIntegrationConnectionUpdated
|
||||||
| V2EventCatalogUpdated
|
| V2EventCatalogUpdated
|
||||||
| V2EventSessionCreated
|
| V2EventSessionCreated
|
||||||
| V2EventSessionUpdated
|
| V2EventSessionUpdated
|
||||||
@ -2899,6 +2908,21 @@ export type EventTuiSessionSelect2 = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CredentialValue = CredentialOAuth | CredentialKey
|
||||||
|
|
||||||
|
export type IntegrationInputs = {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod
|
||||||
|
|
||||||
|
export type IntegrationRef = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkillV2Source = SkillV2DirectorySource | SkillV2UrlSource | SkillV2EmbeddedSource
|
||||||
|
|
||||||
export type MoveSessionDestination = {
|
export type MoveSessionDestination = {
|
||||||
directory: string
|
directory: string
|
||||||
}
|
}
|
||||||
@ -4145,7 +4169,7 @@ export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
|
|||||||
export type IntegrationInfo = {
|
export type IntegrationInfo = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
methods: Array<IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod>
|
methods: Array<IntegrationMethod>
|
||||||
connections: Array<ConnectionInfo>
|
connections: Array<ConnectionInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4240,6 +4264,23 @@ export type V2EventIntegrationUpdated = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type V2EventIntegrationConnectionUpdated = {
|
||||||
|
id: string
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
durable?: {
|
||||||
|
aggregateID: string
|
||||||
|
seq: number
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
location?: LocationRef
|
||||||
|
type: "integration.connection.updated"
|
||||||
|
data: {
|
||||||
|
integrationID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type V2EventCatalogUpdated = {
|
export type V2EventCatalogUpdated = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
@ -5981,6 +6022,14 @@ export type EventIntegrationUpdated = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventIntegrationConnectionUpdated = {
|
||||||
|
id: string
|
||||||
|
type: "integration.connection.updated"
|
||||||
|
properties: {
|
||||||
|
integrationID: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type EventCatalogUpdated = {
|
export type EventCatalogUpdated = {
|
||||||
id: string
|
id: string
|
||||||
type: "catalog.updated"
|
type: "catalog.updated"
|
||||||
@ -6869,6 +6918,40 @@ export type EventGlobalDisposed = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CredentialOAuth = {
|
||||||
|
type: "oauth"
|
||||||
|
methodID: string
|
||||||
|
refresh: string
|
||||||
|
access: string
|
||||||
|
expires: number
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CredentialKey = {
|
||||||
|
type: "key"
|
||||||
|
key: string
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkillV2DirectorySource = {
|
||||||
|
type: "directory"
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkillV2UrlSource = {
|
||||||
|
type: "url"
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkillV2EmbeddedSource = {
|
||||||
|
type: "embedded"
|
||||||
|
skill: SkillV2Info
|
||||||
|
}
|
||||||
|
|
||||||
export type BadRequestError = {
|
export type BadRequestError = {
|
||||||
name: "BadRequest"
|
name: "BadRequest"
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user