Просмотр исходного кода

allow plugins to create custom auth providers

Dax Raad 6 месяцев назад
Родитель
Сommit
a433766a31

+ 0 - 84
packages/opencode/src/auth/anthropic.ts

@@ -1,84 +0,0 @@
-import { generatePKCE } from "@openauthjs/openauth/pkce"
-import { Auth } from "./index"
-
-export namespace AuthAnthropic {
-  const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
-
-  export async function authorize(mode: "max" | "console") {
-    const pkce = await generatePKCE()
-
-    const url = new URL(
-      `https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`,
-      import.meta.url,
-    )
-    url.searchParams.set("code", "true")
-    url.searchParams.set("client_id", CLIENT_ID)
-    url.searchParams.set("response_type", "code")
-    url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
-    url.searchParams.set("scope", "org:create_api_key user:profile user:inference")
-    url.searchParams.set("code_challenge", pkce.challenge)
-    url.searchParams.set("code_challenge_method", "S256")
-    url.searchParams.set("state", pkce.verifier)
-    return {
-      url: url.toString(),
-      verifier: pkce.verifier,
-    }
-  }
-
-  export async function exchange(code: string, verifier: string) {
-    const splits = code.split("#")
-    const result = await fetch("https://console.anthropic.com/v1/oauth/token", {
-      method: "POST",
-      headers: {
-        "Content-Type": "application/json",
-      },
-      body: JSON.stringify({
-        code: splits[0],
-        state: splits[1],
-        grant_type: "authorization_code",
-        client_id: CLIENT_ID,
-        redirect_uri: "https://console.anthropic.com/oauth/code/callback",
-        code_verifier: verifier,
-      }),
-    })
-    if (!result.ok) throw new ExchangeFailed()
-    const json = await result.json()
-    return {
-      refresh: json.refresh_token as string,
-      access: json.access_token as string,
-      expires: Date.now() + json.expires_in * 1000,
-    }
-  }
-
-  export async function access() {
-    const info = await Auth.get("anthropic")
-    if (!info || info.type !== "oauth") return
-    if (info.access && info.expires > Date.now()) return info.access
-    const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
-      method: "POST",
-      headers: {
-        "Content-Type": "application/json",
-      },
-      body: JSON.stringify({
-        grant_type: "refresh_token",
-        refresh_token: info.refresh,
-        client_id: CLIENT_ID,
-      }),
-    })
-    if (!response.ok) return
-    const json = await response.json()
-    await Auth.set("anthropic", {
-      type: "oauth",
-      refresh: json.refresh_token as string,
-      access: json.access_token as string,
-      expires: Date.now() + json.expires_in * 1000,
-    })
-    return json.access_token as string
-  }
-
-  export class ExchangeFailed extends Error {
-    constructor() {
-      super("Exchange failed")
-    }
-  }
-}

+ 0 - 19
packages/opencode/src/auth/copilot.ts

@@ -1,19 +0,0 @@
-import { Global } from "../global"
-import { lazy } from "../util/lazy"
-import path from "path"
-
-export const AuthCopilot = lazy(async () => {
-  const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
-  const exists = await file.exists()
-  const response = fetch("https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts")
-    .then((x) => Bun.write(file, x))
-    .catch(() => {})
-
-  if (!exists) {
-    const worked = await response
-    if (!worked) return
-  }
-  const result = await import(file.name!).catch(() => {})
-  if (!result) return
-  return result.AuthCopilot
-})

+ 25 - 19
packages/opencode/src/auth/index.ts

@@ -4,25 +4,31 @@ import fs from "fs/promises"
 import { z } from "zod"
 import { z } from "zod"
 
 
 export namespace Auth {
 export namespace Auth {
-  export const Oauth = z.object({
-    type: z.literal("oauth"),
-    refresh: z.string(),
-    access: z.string(),
-    expires: z.number(),
-  })
-
-  export const Api = z.object({
-    type: z.literal("api"),
-    key: z.string(),
-  })
-
-  export const WellKnown = z.object({
-    type: z.literal("wellknown"),
-    key: z.string(),
-    token: z.string(),
-  })
-
-  export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown])
+  export const Oauth = z
+    .object({
+      type: z.literal("oauth"),
+      refresh: z.string(),
+      access: z.string(),
+      expires: z.number(),
+    })
+    .openapi({ ref: "OAuth" })
+
+  export const Api = z
+    .object({
+      type: z.literal("api"),
+      key: z.string(),
+    })
+    .openapi({ ref: "ApiAuth" })
+
+  export const WellKnown = z
+    .object({
+      type: z.literal("wellknown"),
+      key: z.string(),
+      token: z.string(),
+    })
+    .openapi({ ref: "WellKnownAuth" })
+
+  export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).openapi({ ref: "Auth" })
   export type Info = z.infer<typeof Info>
   export type Info = z.infer<typeof Info>
 
 
   const filepath = path.join(Global.Path.data, "auth.json")
   const filepath = path.join(Global.Path.data, "auth.json")

+ 150 - 213
packages/opencode/src/cli/cmd/auth.ts

@@ -1,5 +1,3 @@
-import { AuthAnthropic } from "../../auth/anthropic"
-import { AuthCopilot } from "../../auth/copilot"
 import { Auth } from "../../auth"
 import { Auth } from "../../auth"
 import { cmd } from "./cmd"
 import { cmd } from "./cmd"
 import * as prompts from "@clack/prompts"
 import * as prompts from "@clack/prompts"
@@ -10,6 +8,8 @@ import { map, pipe, sortBy, values } from "remeda"
 import path from "path"
 import path from "path"
 import os from "os"
 import os from "os"
 import { Global } from "../../global"
 import { Global } from "../../global"
+import { Plugin } from "../../plugin"
+import { App } from "../../app/app"
 
 
 export const AuthCommand = cmd({
 export const AuthCommand = cmd({
   command: "auth",
   command: "auth",
@@ -75,242 +75,179 @@ export const AuthLoginCommand = cmd({
       type: "string",
       type: "string",
     }),
     }),
   async handler(args) {
   async handler(args) {
-    UI.empty()
-    prompts.intro("Add credential")
-    if (args.url) {
-      const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
-      prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
-      const proc = Bun.spawn({
-        cmd: wellknown.auth.command,
-        stdout: "pipe",
-      })
-      const exit = await proc.exited
-      if (exit !== 0) {
-        prompts.log.error("Failed")
+    await App.provide({ cwd: process.cwd() }, async () => {
+      UI.empty()
+      prompts.intro("Add credential")
+      if (args.url) {
+        const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
+        prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
+        const proc = Bun.spawn({
+          cmd: wellknown.auth.command,
+          stdout: "pipe",
+        })
+        const exit = await proc.exited
+        if (exit !== 0) {
+          prompts.log.error("Failed")
+          prompts.outro("Done")
+          return
+        }
+        const token = await new Response(proc.stdout).text()
+        await Auth.set(args.url, {
+          type: "wellknown",
+          key: wellknown.auth.env,
+          token: token.trim(),
+        })
+        prompts.log.success("Logged into " + args.url)
         prompts.outro("Done")
         prompts.outro("Done")
         return
         return
       }
       }
-      const token = await new Response(proc.stdout).text()
-      await Auth.set(args.url, {
-        type: "wellknown",
-        key: wellknown.auth.env,
-        token: token.trim(),
-      })
-      prompts.log.success("Logged into " + args.url)
-      prompts.outro("Done")
-      return
-    }
-    await ModelsDev.refresh().catch(() => {})
-    const providers = await ModelsDev.get()
-    const priority: Record<string, number> = {
-      anthropic: 0,
-      "github-copilot": 1,
-      openai: 2,
-      google: 3,
-      openrouter: 4,
-      vercel: 5,
-    }
-    let provider = await prompts.autocomplete({
-      message: "Select provider",
-      maxItems: 8,
-      options: [
-        ...pipe(
-          providers,
-          values(),
-          sortBy(
-            (x) => priority[x.id] ?? 99,
-            (x) => x.name ?? x.id,
-          ),
-          map((x) => ({
-            label: x.name,
-            value: x.id,
-            hint: priority[x.id] === 0 ? "recommended" : undefined,
-          })),
-        ),
-        {
-          value: "other",
-          label: "Other",
-        },
-      ],
-    })
-
-    if (prompts.isCancel(provider)) throw new UI.CancelledError()
-
-    if (provider === "other") {
-      provider = await prompts.text({
-        message: "Enter provider id",
-        validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
-      })
-      if (prompts.isCancel(provider)) throw new UI.CancelledError()
-      provider = provider.replace(/^@ai-sdk\//, "")
-      if (prompts.isCancel(provider)) throw new UI.CancelledError()
-      prompts.log.warn(
-        `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
-      )
-    }
-
-    if (provider === "amazon-bedrock") {
-      prompts.log.info(
-        "Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
-      )
-      prompts.outro("Done")
-      return
-    }
-
-    if (provider === "anthropic") {
-      const method = await prompts.select({
-        message: "Login method",
+      await ModelsDev.refresh().catch(() => {})
+      const providers = await ModelsDev.get()
+      const priority: Record<string, number> = {
+        anthropic: 0,
+        "github-copilot": 1,
+        openai: 2,
+        google: 3,
+        openrouter: 4,
+        vercel: 5,
+      }
+      let provider = await prompts.autocomplete({
+        message: "Select provider",
+        maxItems: 8,
         options: [
         options: [
+          ...pipe(
+            providers,
+            values(),
+            sortBy(
+              (x) => priority[x.id] ?? 99,
+              (x) => x.name ?? x.id,
+            ),
+            map((x) => ({
+              label: x.name,
+              value: x.id,
+              hint: priority[x.id] === 0 ? "recommended" : undefined,
+            })),
+          ),
           {
           {
-            label: "Claude Pro/Max",
-            value: "max",
-          },
-          {
-            label: "Create API Key",
-            value: "console",
-          },
-          {
-            label: "Manually enter API Key",
-            value: "api",
+            value: "other",
+            label: "Other",
           },
           },
         ],
         ],
       })
       })
-      if (prompts.isCancel(method)) throw new UI.CancelledError()
-
-      if (method === "max") {
-        // some weird bug where program exits without this
-        await new Promise((resolve) => setTimeout(resolve, 10))
-        const { url, verifier } = await AuthAnthropic.authorize("max")
-        prompts.note("Trying to open browser...")
-        try {
-          await open(url)
-        } catch (e) {
-          prompts.log.error(
-            "Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
-          )
-        }
-        prompts.log.info(url)
 
 
-        const code = await prompts.text({
-          message: "Paste the authorization code here: ",
-          validate: (x) => (x && x.length > 0 ? undefined : "Required"),
-        })
-        if (prompts.isCancel(code)) throw new UI.CancelledError()
+      if (prompts.isCancel(provider)) throw new UI.CancelledError()
 
 
-        try {
-          const credentials = await AuthAnthropic.exchange(code, verifier)
-          await Auth.set("anthropic", {
-            type: "oauth",
-            refresh: credentials.refresh,
-            access: credentials.access,
-            expires: credentials.expires,
+      const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
+      if (plugin && plugin.auth) {
+        let index = 0
+        if (plugin.auth.methods.length > 1) {
+          const method = await prompts.select({
+            message: "Login method",
+            options: [
+              ...plugin.auth.methods.map((x, index) => ({
+                label: x.label,
+                value: index.toString(),
+              })),
+            ],
           })
           })
-          prompts.log.success("Login successful")
-        } catch {
-          prompts.log.error("Invalid code")
+          if (prompts.isCancel(method)) throw new UI.CancelledError()
+          index = parseInt(method)
         }
         }
-        prompts.outro("Done")
-        return
-      }
+        const method = plugin.auth.methods[index]
+        if (method.type === "oauth") {
+          await new Promise((resolve) => setTimeout(resolve, 10))
+          const authorize = await method.authorize()
 
 
-      if (method === "console") {
-        // some weird bug where program exits without this
-        await new Promise((resolve) => setTimeout(resolve, 10))
-        const { url, verifier } = await AuthAnthropic.authorize("console")
-        prompts.note("Trying to open browser...")
-        try {
-          await open(url)
-        } catch (e) {
-          prompts.log.error(
-            "Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
-          )
-        }
-        prompts.log.info(url)
-
-        const code = await prompts.text({
-          message: "Paste the authorization code here: ",
-          validate: (x) => (x && x.length > 0 ? undefined : "Required"),
-        })
-        if (prompts.isCancel(code)) throw new UI.CancelledError()
+          if (authorize.url) {
+            try {
+              await open(authorize.url)
+            } catch (e) {}
+            prompts.log.info("Go to: " + authorize.url)
+          }
 
 
-        try {
-          const credentials = await AuthAnthropic.exchange(code, verifier)
-          const accessToken = credentials.access
-          const response = await fetch("https://api.anthropic.com/api/oauth/claude_cli/create_api_key", {
-            method: "POST",
-            headers: {
-              Authorization: `Bearer ${accessToken}`,
-              "Content-Type": "application/x-www-form-urlencoded",
-              Accept: "application/json, text/plain, */*",
-            },
-          })
-          if (!response.ok) {
-            throw new Error("Failed to create API key")
+          if (authorize.method === "auto") {
+            if (authorize.instructions) {
+              prompts.log.info(authorize.instructions)
+            }
+            const spinner = prompts.spinner()
+            spinner.start("Waiting for authorization...")
+            const result = await authorize.callback()
+            if (result.type === "failed") {
+              spinner.stop("Failed to authorize", 1)
+            }
+            if (result.type === "success") {
+              await Auth.set(provider, {
+                type: "oauth",
+                refresh: result.refresh,
+                access: result.access,
+                expires: result.expires,
+              })
+              spinner.stop("Login successful")
+            }
           }
           }
-          const json = await response.json()
-          await Auth.set("anthropic", {
-            type: "api",
-            key: json.raw_key,
-          })
 
 
-          prompts.log.success("Login successful - API key created and saved")
-        } catch (error) {
-          prompts.log.error("Invalid code or failed to create API key")
+          if (authorize.method === "code") {
+            const code = await prompts.text({
+              message: "Paste the authorization code here: ",
+              validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+            })
+            if (prompts.isCancel(code)) throw new UI.CancelledError()
+            const result = await authorize.callback(code)
+            if (result.type === "failed") {
+              prompts.log.error("Failed to authorize")
+            }
+            if (result.type === "success") {
+              await Auth.set(provider, {
+                type: "oauth",
+                refresh: result.refresh,
+                access: result.access,
+                expires: result.expires,
+              })
+              prompts.log.success("Login successful")
+            }
+          }
+          prompts.outro("Done")
+          return
         }
         }
-        prompts.outro("Done")
-        return
       }
       }
-    }
-
-    const copilot = await AuthCopilot()
-    if (provider === "github-copilot" && copilot) {
-      await new Promise((resolve) => setTimeout(resolve, 10))
-      const deviceInfo = await copilot.authorize()
 
 
-      prompts.note(`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`)
-
-      const spinner = prompts.spinner()
-      spinner.start("Waiting for authorization...")
+      if (provider === "other") {
+        provider = await prompts.text({
+          message: "Enter provider id",
+          validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
+        })
+        if (prompts.isCancel(provider)) throw new UI.CancelledError()
+        provider = provider.replace(/^@ai-sdk\//, "")
+        if (prompts.isCancel(provider)) throw new UI.CancelledError()
+        prompts.log.warn(
+          `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
+        )
+      }
 
 
-      while (true) {
-        await new Promise((resolve) => setTimeout(resolve, deviceInfo.interval * 1000))
-        const response = await copilot.poll(deviceInfo.device)
-        if (response.status === "pending") continue
-        if (response.status === "success") {
-          await Auth.set("github-copilot", {
-            type: "oauth",
-            refresh: response.refresh,
-            access: response.access,
-            expires: response.expires,
-          })
-          spinner.stop("Login successful")
-          break
-        }
-        if (response.status === "failed") {
-          spinner.stop("Failed to authorize", 1)
-          break
-        }
+      if (provider === "amazon-bedrock") {
+        prompts.log.info(
+          "Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
+        )
+        prompts.outro("Done")
+        return
       }
       }
 
 
-      prompts.outro("Done")
-      return
-    }
+      if (provider === "vercel") {
+        prompts.log.info("You can create an api key in the dashboard")
+      }
 
 
-    if (provider === "vercel") {
-      prompts.log.info("You can create an api key in the dashboard")
-    }
+      const key = await prompts.password({
+        message: "Enter your API key",
+        validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+      })
+      if (prompts.isCancel(key)) throw new UI.CancelledError()
+      await Auth.set(provider, {
+        type: "api",
+        key,
+      })
 
 
-    const key = await prompts.password({
-      message: "Enter your API key",
-      validate: (x) => (x && x.length > 0 ? undefined : "Required"),
-    })
-    if (prompts.isCancel(key)) throw new UI.CancelledError()
-    await Auth.set(provider, {
-      type: "api",
-      key,
+      prompts.outro("Done")
     })
     })
-
-    prompts.outro("Done")
   },
   },
 })
 })
 
 

+ 1 - 0
packages/opencode/src/flag/flag.ts

@@ -4,6 +4,7 @@ export namespace Flag {
   export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
   export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
   export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
   export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
   export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
   export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
+  export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
 
 
   function truthy(key: string) {
   function truthy(key: string) {
     const value = process.env[key]?.toLowerCase()
     const value = process.env[key]?.toLowerCase()

+ 19 - 7
packages/opencode/src/plugin/index.ts

@@ -6,6 +6,7 @@ import { Log } from "../util/log"
 import { createOpencodeClient } from "@opencode-ai/sdk"
 import { createOpencodeClient } from "@opencode-ai/sdk"
 import { Server } from "../server/server"
 import { Server } from "../server/server"
 import { BunProc } from "../bun"
 import { BunProc } from "../bun"
+import { Flag } from "../flag/flag"
 
 
 export namespace Plugin {
 export namespace Plugin {
   const log = Log.create({ service: "plugin" })
   const log = Log.create({ service: "plugin" })
@@ -17,7 +18,17 @@ export namespace Plugin {
     })
     })
     const config = await Config.get()
     const config = await Config.get()
     const hooks = []
     const hooks = []
-    for (let plugin of config.plugin ?? []) {
+    const input = {
+      client,
+      app,
+      $: Bun.$,
+    }
+    const plugins = [...(config.plugin ?? [])]
+    if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
+      plugins.push("opencode-copilot-auth")
+      plugins.push("opencode-anthropic-auth")
+    }
+    for (let plugin of plugins) {
       log.info("loading plugin", { path: plugin })
       log.info("loading plugin", { path: plugin })
       if (!plugin.startsWith("file://")) {
       if (!plugin.startsWith("file://")) {
         const [pkg, version] = plugin.split("@")
         const [pkg, version] = plugin.split("@")
@@ -25,22 +36,19 @@ export namespace Plugin {
       }
       }
       const mod = await import(plugin)
       const mod = await import(plugin)
       for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
       for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
-        const init = await fn({
-          client,
-          app,
-          $: Bun.$,
-        })
+        const init = await fn(input)
         hooks.push(init)
         hooks.push(init)
       }
       }
     }
     }
 
 
     return {
     return {
       hooks,
       hooks,
+      input,
     }
     }
   })
   })
 
 
   export async function trigger<
   export async function trigger<
-    Name extends keyof Required<Hooks>,
+    Name extends Exclude<keyof Required<Hooks>, "auth" | "event">,
     Input = Parameters<Required<Hooks>[Name]>[0],
     Input = Parameters<Required<Hooks>[Name]>[0],
     Output = Parameters<Required<Hooks>[Name]>[1],
     Output = Parameters<Required<Hooks>[Name]>[1],
   >(name: Name, input: Input, output: Output): Promise<Output> {
   >(name: Name, input: Input, output: Output): Promise<Output> {
@@ -56,6 +64,10 @@ export namespace Plugin {
     return output
     return output
   }
   }
 
 
+  export async function list() {
+    return state().then((x) => x.hooks)
+  }
+
   export function init() {
   export function init() {
     Bus.subscribeAll(async (input) => {
     Bus.subscribeAll(async (input) => {
       const hooks = await state().then((x) => x.hooks)
       const hooks = await state().then((x) => x.hooks)

+ 17 - 97
packages/opencode/src/provider/provider.ts

@@ -5,8 +5,7 @@ import { mergeDeep, sortBy } from "remeda"
 import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
 import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
 import { Log } from "../util/log"
 import { Log } from "../util/log"
 import { BunProc } from "../bun"
 import { BunProc } from "../bun"
-import { AuthAnthropic } from "../auth/anthropic"
-import { AuthCopilot } from "../auth/copilot"
+import { Plugin } from "../plugin"
 import { ModelsDev } from "./models"
 import { ModelsDev } from "./models"
 import { NamedError } from "../util/error"
 import { NamedError } from "../util/error"
 import { Auth } from "../auth"
 import { Auth } from "../auth"
@@ -26,103 +25,13 @@ export namespace Provider {
   type Source = "env" | "config" | "custom" | "api"
   type Source = "env" | "config" | "custom" | "api"
 
 
   const CUSTOM_LOADERS: Record<string, CustomLoader> = {
   const CUSTOM_LOADERS: Record<string, CustomLoader> = {
-    async anthropic(provider) {
-      const access = await AuthAnthropic.access()
-      if (!access)
-        return {
-          autoload: false,
-          options: {
-            headers: {
-              "anthropic-beta":
-                "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
-            },
-          },
-        }
-      for (const model of Object.values(provider.models)) {
-        model.cost = {
-          input: 0,
-          output: 0,
-        }
-      }
-      return {
-        autoload: true,
-        options: {
-          apiKey: "",
-          async fetch(input: any, init: any) {
-            const access = await AuthAnthropic.access()
-            const headers = {
-              ...init.headers,
-              authorization: `Bearer ${access}`,
-              "anthropic-beta":
-                "oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
-            }
-            delete headers["x-api-key"]
-            return fetch(input, {
-              ...init,
-              headers,
-            })
-          },
-        },
-      }
-    },
-    "github-copilot": async (provider) => {
-      const copilot = await AuthCopilot()
-      if (!copilot) return { autoload: false }
-      let info = await Auth.get("github-copilot")
-      if (!info || info.type !== "oauth") return { autoload: false }
-
-      if (provider && provider.models) {
-        for (const model of Object.values(provider.models)) {
-          model.cost = {
-            input: 0,
-            output: 0,
-          }
-        }
-      }
-
+    async anthropic() {
       return {
       return {
-        autoload: true,
+        autoload: false,
         options: {
         options: {
-          apiKey: "",
-          async fetch(input: any, init: any) {
-            const info = await Auth.get("github-copilot")
-            if (!info || info.type !== "oauth") return
-            if (!info.access || info.expires < Date.now()) {
-              const tokens = await copilot.access(info.refresh)
-              if (!tokens) throw new Error("GitHub Copilot authentication expired")
-              await Auth.set("github-copilot", {
-                type: "oauth",
-                ...tokens,
-              })
-              info.access = tokens.access
-            }
-            let isAgentCall = false
-            let isVisionRequest = false
-            try {
-              const body = typeof init.body === "string" ? JSON.parse(init.body) : init.body
-              if (body?.messages) {
-                isAgentCall = body.messages.some((msg: any) => msg.role && ["tool", "assistant"].includes(msg.role))
-                isVisionRequest = body.messages.some(
-                  (msg: any) =>
-                    Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
-                )
-              }
-            } catch {}
-            const headers: Record<string, string> = {
-              ...init.headers,
-              ...copilot.HEADERS,
-              Authorization: `Bearer ${info.access}`,
-              "Openai-Intent": "conversation-edits",
-              "X-Initiator": isAgentCall ? "agent" : "user",
-            }
-            if (isVisionRequest) {
-              headers["Copilot-Vision-Request"] = "true"
-            }
-            delete headers["x-api-key"]
-            return fetch(input, {
-              ...init,
-              headers,
-            })
+          headers: {
+            "anthropic-beta":
+              "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
           },
           },
         },
         },
       }
       }
@@ -350,6 +259,17 @@ export namespace Provider {
       }
       }
     }
     }
 
 
+    for (const plugin of await Plugin.list()) {
+      if (!plugin.auth) continue
+      const providerID = plugin.auth.provider
+      if (disabled.has(providerID)) continue
+      const auth = await Auth.get(providerID)
+      if (!auth) continue
+      if (!plugin.auth.loader) continue
+      const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
+      mergeProvider(plugin.auth.provider, options ?? {}, "custom")
+    }
+
     // load config
     // load config
     for (const [providerID, provider] of configProviders) {
     for (const [providerID, provider] of configProviders) {
       mergeProvider(providerID, provider.options ?? {}, "config")
       mergeProvider(providerID, provider.options ?? {}, "config")

+ 32 - 0
packages/opencode/src/server/server.ts

@@ -20,6 +20,7 @@ import { callTui, TuiRoute } from "./tui"
 import { Permission } from "../permission"
 import { Permission } from "../permission"
 import { lazy } from "../util/lazy"
 import { lazy } from "../util/lazy"
 import { Agent } from "../agent/agent"
 import { Agent } from "../agent/agent"
+import { Auth } from "../auth"
 
 
 const ERRORS = {
 const ERRORS = {
   400: {
   400: {
@@ -1120,6 +1121,37 @@ export namespace Server {
         async (c) => c.json(await callTui(c)),
         async (c) => c.json(await callTui(c)),
       )
       )
       .route("/tui/control", TuiRoute)
       .route("/tui/control", TuiRoute)
+      .put(
+        "/auth/:id",
+        describeRoute({
+          description: "Set authentication credentials",
+          operationId: "auth.set",
+          responses: {
+            200: {
+              description: "Successfully set authentication credentials",
+              content: {
+                "application/json": {
+                  schema: resolver(z.boolean()),
+                },
+              },
+            },
+            ...ERRORS,
+          },
+        }),
+        zValidator(
+          "param",
+          z.object({
+            id: z.string(),
+          }),
+        ),
+        zValidator("json", Auth.Info),
+        async (c) => {
+          const id = c.req.valid("param").id
+          const info = c.req.valid("json")
+          await Auth.set(id, info)
+          return c.json(true)
+        },
+      )
 
 
     return result
     return result
   })
   })

+ 54 - 1
packages/plugin/src/index.ts

@@ -1,4 +1,14 @@
-import type { Event, createOpencodeClient, App, Model, Provider, Permission, UserMessage, Part } from "@opencode-ai/sdk"
+import type {
+  Event,
+  createOpencodeClient,
+  App,
+  Model,
+  Provider,
+  Permission,
+  UserMessage,
+  Part,
+  Auth,
+} from "@opencode-ai/sdk"
 import type { BunShell } from "./shell"
 import type { BunShell } from "./shell"
 
 
 export type PluginInput = {
 export type PluginInput = {
@@ -10,6 +20,49 @@ export type Plugin = (input: PluginInput) => Promise<Hooks>
 
 
 export interface Hooks {
 export interface Hooks {
   event?: (input: { event: Event }) => Promise<void>
   event?: (input: { event: Event }) => Promise<void>
+  auth?: {
+    provider: string
+    loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
+    methods: (
+      | {
+          type: "oauth"
+          label: string
+          authorize(): Promise<
+            { url: string; instructions: string } & (
+              | {
+                  method: "auto"
+                  callback(): Promise<
+                    | {
+                        type: "success"
+                        refresh: string
+                        access: string
+                        expires: number
+                      }
+                    | {
+                        type: "failed"
+                      }
+                  >
+                }
+              | {
+                  method: "code"
+                  callback(code: string): Promise<
+                    | {
+                        type: "success"
+                        refresh: string
+                        access: string
+                        expires: number
+                      }
+                    | {
+                        type: "failed"
+                      }
+                  >
+                }
+            )
+          >
+        }
+      | { type: "api"; label: string }
+    )[]
+  }
   /**
   /**
    * Called when a new message is received
    * Called when a new message is received
    */
    */

+ 20 - 0
packages/sdk/js/src/gen/sdk.gen.ts

@@ -77,6 +77,9 @@ import type {
   TuiClearPromptResponses,
   TuiClearPromptResponses,
   TuiExecuteCommandData,
   TuiExecuteCommandData,
   TuiExecuteCommandResponses,
   TuiExecuteCommandResponses,
+  AuthSetData,
+  AuthSetResponses,
+  AuthSetErrors,
 } from "./types.gen.js"
 } from "./types.gen.js"
 import { client as _heyApiClient } from "./client.gen.js"
 import { client as _heyApiClient } from "./client.gen.js"
 
 
@@ -517,6 +520,22 @@ class Tui extends _HeyApiClient {
   }
   }
 }
 }
 
 
+class Auth extends _HeyApiClient {
+  /**
+   * Set authentication credentials
+   */
+  public set<ThrowOnError extends boolean = false>(options: Options<AuthSetData, ThrowOnError>) {
+    return (options.client ?? this._client).put<AuthSetResponses, AuthSetErrors, ThrowOnError>({
+      url: "/auth/{id}",
+      ...options,
+      headers: {
+        "Content-Type": "application/json",
+        ...options.headers,
+      },
+    })
+  }
+}
+
 export class OpencodeClient extends _HeyApiClient {
 export class OpencodeClient extends _HeyApiClient {
   /**
   /**
    * Respond to a permission request
    * Respond to a permission request
@@ -544,4 +563,5 @@ export class OpencodeClient extends _HeyApiClient {
   find = new Find({ client: this._client })
   find = new Find({ client: this._client })
   file = new File({ client: this._client })
   file = new File({ client: this._client })
   tui = new Tui({ client: this._client })
   tui = new Tui({ client: this._client })
+  auth = new Auth({ client: this._client })
 }
 }

+ 56 - 0
packages/sdk/js/src/gen/types.gen.ts

@@ -1105,6 +1105,35 @@ export type Agent = {
   }
   }
 }
 }
 
 
+export type Auth =
+  | ({
+      type: "oauth"
+    } & OAuth)
+  | ({
+      type: "api"
+    } & ApiAuth)
+  | ({
+      type: "wellknown"
+    } & WellKnownAuth)
+
+export type OAuth = {
+  type: "oauth"
+  refresh: string
+  access: string
+  expires: number
+}
+
+export type ApiAuth = {
+  type: "api"
+  key: string
+}
+
+export type WellKnownAuth = {
+  type: "wellknown"
+  key: string
+  token: string
+}
+
 export type EventSubscribeData = {
 export type EventSubscribeData = {
   body?: never
   body?: never
   path?: never
   path?: never
@@ -1858,6 +1887,33 @@ export type TuiExecuteCommandResponses = {
 
 
 export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses]
 export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses]
 
 
+export type AuthSetData = {
+  body?: Auth
+  path: {
+    id: string
+  }
+  query?: never
+  url: "/auth/{id}"
+}
+
+export type AuthSetErrors = {
+  /**
+   * Bad request
+   */
+  400: _Error
+}
+
+export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]
+
+export type AuthSetResponses = {
+  /**
+   * Successfully set authentication credentials
+   */
+  200: boolean
+}
+
+export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]
+
 export type ClientOptions = {
 export type ClientOptions = {
   baseUrl: `${string}://${string}` | (string & {})
   baseUrl: `${string}://${string}` | (string & {})
 }
 }