feat(core): add opencode integration (#33555)

This commit is contained in:
Dax 2026-06-23 19:45:02 -04:00 committed by GitHub
commit cf80b5c470
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1061 additions and 439 deletions

View file

@ -20,13 +20,13 @@ export class OAuth extends Schema.Class<OAuth>("Credential.OAuth")({
refresh: Schema.String,
access: Schema.String,
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")({
type: Schema.Literal("key"),
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])

View file

@ -90,7 +90,9 @@ export const EnvMethod = Schema.Struct({
}).annotate({ identifier: "Integration.EnvMethod" })
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 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)),
}) {}
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 = {
readonly url: string
@ -108,11 +111,11 @@ export type OAuthAuthorization = {
} & (
| {
readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Value, unknown>
readonly callback: Effect.Effect<Credential.OAuth, unknown>
}
| {
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 authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
readonly label?: (credential: Credential.OAuth) => string | undefined
}
export interface KeyImplementation {
@ -178,12 +182,17 @@ export const Event = {
type: "integration.updated",
schema: {},
}),
ConnectionUpdated: EventV2.define({
type: "integration.connection.updated",
schema: { integrationID: ID },
}),
}
export type Ref = {
id: ID
name: string
}
export const Ref = Schema.Struct({
id: ID,
name: Schema.String,
}).annotate({ identifier: "Integration.Ref" })
export type Ref = typeof Ref.Type
type Entry = {
ref: Types.DeepMutable<Ref>
@ -215,7 +224,11 @@ export interface Interface extends State.Transformable<Draft> {
readonly list: () => Effect.Effect<Info[]>
readonly connection: {
/** 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. */
readonly key: (input: {
/** Integration receiving the credential. */
@ -385,7 +398,7 @@ export const locationLayer = Layer.effect(
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 result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID)
@ -397,14 +410,13 @@ export const locationLayer = Layer.effect(
})
if (!result) return
if (Exit.isSuccess(exit)) {
const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID)
yield* credentials.create({
integrationID: result.integrationID,
label: result.label,
value:
exit.value.type === "oauth"
? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
: exit.value,
label: result.label ?? implementation?.label?.(exit.value),
value: exit.value,
})
yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID })
yield* events.publish(Event.Updated, {})
}
yield* close(result.scope)
@ -445,10 +457,29 @@ export const locationLayer = Layer.effect(
).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connection: {
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
active: Effect.fn("Integration.connection.active")(function* (id) {
const entry = state.get().integrations.get(id)
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) {
const method = state
.get()
@ -460,6 +491,7 @@ export const locationLayer = Layer.effect(
label: input.label,
value: new Credential.Key({ type: "key", key: input.key }),
})
yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* events.publish(Event.Updated, {})
}),
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) {
const credential = yield* credentials.get(credentialID)
yield* credentials.update(credentialID, updates)
if (credential) {
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* events.publish(Event.Updated, {})
}),
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
yield* credentials.remove(credentialID)
if (credential) {
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* events.publish(Event.Updated, {})
}),
},

View file

@ -6,6 +6,7 @@ import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { Credential } from "../credential"
import { Integration } from "../integration"
import { ModelV2 } from "../model"
import { PluginV2 } from "../plugin"
@ -97,6 +98,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
integration: {
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) =>
integration.transform((draft) =>
callback({
@ -107,6 +115,62 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
method: {
list: (id) => draft.method.list(Integration.ID.make(id)),
update: (input) => {
if ("authorize" in input) {
const methodID = Integration.MethodID.make(input.method.id)
const refresh = input.refresh
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),

View file

@ -23,6 +23,7 @@ import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { Reference } from "../reference"
import { SkillV2 } from "../skill"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
@ -38,6 +39,7 @@ export type Requirements =
| FileSystem.Service
| FSUtil.Service
| Global.Service
| HttpClient.HttpClient
| Integration.Service
| Location.Service
| ModelsDev.Service
@ -69,6 +71,7 @@ export const locationLayer = Layer.effectDiscard(
const fs = yield* FSUtil.Service
const filesystem = yield* FileSystem.Service
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const skill = yield* SkillV2.Service
const reference = yield* Reference.Service
const add = <R>(input: Plugin<R>) => {
@ -90,6 +93,7 @@ export const locationLayer = Layer.effectDiscard(
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(FileSystem.Service, filesystem),
Effect.provideService(Global.Service, global),
Effect.provideService(HttpClient.HttpClient, http),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, reference),
),
@ -115,4 +119,5 @@ export const locationLayer = Layer.effectDiscard(
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(FetchHttpClient.layer),
)

View file

@ -65,6 +65,11 @@ export function fromPromise(plugin: Plugin) {
integration: {
transform: transform(host.integration),
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: {
add: (input) => {

View file

@ -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>`

View file

@ -1,15 +1,155 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect } from "effect"
import type { Scope } from "effect"
import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version"
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({
id: "openai",
effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service
yield* integrations.transform((draft) => {
yield* ctx.integration.transform((draft) => {
draft.method.update(browser)
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>`

View file

@ -1,32 +1,343 @@
import { Effect } from "effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
import { Duration, Effect, Schema, Stream } from "effect"
import type { Scope } from "effect"
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 { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { ProviderV2 } from "../../provider"
export const OpencodePlugin = define({
id: "opencode",
effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service
let hasKey = false
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.opencode)
if (!item) return
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
hasKey = Boolean(
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
)
evt.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.request.body.apiKey = "public"
})
if (hasKey) return
for (const model of item.models.values()) {
if (!model.cost.some((cost) => cost.input > 0)) continue
evt.model.update(item.provider.id, model.id, (draft) => {
draft.enabled = false
})
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",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const http = yield* HttpClient.HttpClient
let connected = false
let providers: typeof RemoteConfig.Type.providers | undefined
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection
? 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)),
),
)
: 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) return
for (const model of item.models.values()) {
if (!model.cost.some((cost) => cost.input > 0)) continue
catalog.model.update(item.provider.id, model.id, (draft) => {
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)),
)
}

View file

@ -210,7 +210,7 @@ export const locationLayer = Layer.effect(
modelID: session.model.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(
session,
selected,

View file

@ -27,6 +27,7 @@ export class EmbeddedSource extends Schema.Class<EmbeddedSource>("SkillV2.Embedd
export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe(
Schema.toTaggedUnion("type"),
Schema.annotate({ identifier: "SkillV2.Source" }),
withStatics(() => ({
equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => {
if (a.type !== b.type) return false