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

Providers configuration add/remove from settings

paviko 2 месяцев назад
Родитель
Сommit
e876929169

+ 1 - 0
hosts/IDE_BRIDGE.md

@@ -147,6 +147,7 @@ await ideBridge.request("openFile", { path: "/p/file.ts", line: 10 })
 ### Web UI → JetBrains host (handled)
 
 - **openFile** — payload: `{ path: string, line?: number }` → opens file in IDE, responds with `{ replyTo, ok }` or `{ replyTo, ok: false, error }`
+- **openUrl** — payload: `{ url: string }` → opens URL in default browser, responds with `{ replyTo, ok }`
 
 ### Protocol notes
 

+ 4 - 0
hosts/jetbrains-plugin/CHANGELOG.md

@@ -1,5 +1,9 @@
 # OpenCode JetBrains Plugin Changelog
 
+## 2025.11.xx
+
+- Providers can be configured from Settings panel - can be added/removed, also OAuth
+
 ## 25.11.19
 
 - Updated OpenCode to v1.0.78

+ 11 - 0
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/IdeBridge.kt

@@ -1,6 +1,7 @@
 package paviko.opencode.ui
 
 import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.intellij.ide.BrowserUtil
 import com.intellij.openapi.application.ApplicationManager
 import com.intellij.openapi.diagnostic.Logger
 import com.intellij.openapi.editor.LogicalPosition
@@ -85,6 +86,16 @@ object IdeBridge {
                 openFile(project, cleanedPath, startLine0Based, endLine0Based)
                 replyOk(id)
             }
+            "openUrl" -> {
+                val payload = obj.get("payload")
+                val url = payload?.get("url")?.asText() ?: return replyError(id, "missing url")
+                try {
+                    BrowserUtil.browse(url)
+                    replyOk(id)
+                } catch (t: Throwable) {
+                    replyError(id, t.message ?: "Failed to open url")
+                }
+            }
             else -> replyOk(id)
         }
     }

+ 4 - 0
hosts/vscode-plugin/CHANGELOG.md

@@ -5,6 +5,10 @@ All notable changes to the OpenCode VSCode extension will be documented in this
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [25.11.xx] - 2025-11-xx
+
+- Providers can be configured from Settings panel - can be added/removed, also OAuth
+
 ## [25.11.19] - 2025-11-19
 
 - Updated OpenCode to v1.0.78

+ 31 - 0
hosts/vscode-plugin/src/ui/CommunicationBridge.ts

@@ -315,6 +315,28 @@ export class CommunicationBridge implements PluginCommunicator {
     }
   }
 
+  /**
+   * Handle url open request from web UI
+   * @param url URL to open
+   */
+  async handleOpenUrl(url: string): Promise<void> {
+    try {
+      if (!url || url.trim().length === 0) {
+        logger.appendLine("No url provided to open")
+        return
+      }
+
+      await vscode.env.openExternal(vscode.Uri.parse(url))
+      logger.appendLine(`Opened url: ${url}`)
+    } catch (error) {
+      logger.appendLine(`Error opening url: ${error}`)
+      await errorHandler.handleCommunicationError(error instanceof Error ? error : new Error(String(error)), {
+        operation: "openUrl",
+        messageType: "openUrl",
+      })
+    }
+  }
+
   /**
    * Handle state change from web UI
    * @param key Setting key
@@ -397,6 +419,11 @@ export class CommunicationBridge implements PluginCommunicator {
                 if (m.id) {
                   this.webview?.postMessage({ replyTo: m.id, ok: true })
                 }
+              } else if (m && m.type === "openUrl") {
+                await this.handleOpenUrl(m.payload?.url ?? m.url)
+                if (m.id) {
+                  this.webview?.postMessage({ replyTo: m.id, ok: true })
+                }
               } else {
                 // Generic ack for unknown types
                 if (m && m.id) this.webview?.postMessage({ replyTo: m.id, ok: true })
@@ -421,6 +448,10 @@ export class CommunicationBridge implements PluginCommunicator {
               await this.handleOpenFile(message.path)
               break
 
+            case "openUrl":
+              await this.handleOpenUrl(message.url)
+              break
+
             case "settingsChanged":
               await this.handleStateChange(message.key, message.value)
               break

+ 1 - 1
packages/opencode/src/project/state.ts

@@ -57,7 +57,7 @@ export namespace State {
 
       tasks.push(task)
     }
-    entries.delete(key)
+    recordsByKey.delete(key)
     await Promise.all(tasks)
     disposalFinished = true
     log.info("state disposal completed", { key })

+ 1 - 1
packages/opencode/src/provider/models.ts

@@ -69,7 +69,7 @@ export namespace ModelsDev {
   export type Provider = z.infer<typeof Provider>
 
   export async function get() {
-    refresh()
+    await refresh()
     const file = Bun.file(filepath)
     const result = await file.json().catch(() => {})
     if (result) return result as Record<string, Provider>

+ 406 - 19
packages/opencode/src/webgui/server/webgui.ts

@@ -1,16 +1,399 @@
+import { Storage } from "@/storage/storage.ts"
 import { Hono } from "hono"
-import { validator } from "hono-openapi"
+import { validator, describeRoute, resolver } from "hono-openapi"
 import { z } from "zod"
 import * as State from "@/webgui/state/state.ts"
 import { StateSchema } from "@/webgui/state/state.ts"
+import { ModelsDev } from "../../provider/models"
+import { Auth } from "../../auth"
+import { Instance } from "../../project/instance"
+import { mapValues } from "remeda"
+import { Provider } from "@/provider/provider.ts"
 
 const StatePatchSchema = StateSchema.partial()
 
+type AuthSession = {
+  status: "pending" | "success" | "failed"
+  result?: any
+  callback?: (code: string) => Promise<any>
+}
+
+const pendingAuths = new Map<string, AuthSession>()
+
+const ERRORS = {
+  400: {
+    description: "Bad request",
+    content: {
+      "application/json": {
+        schema: resolver(
+          z
+            .object({
+              data: z.any(),
+              errors: z.array(z.record(z.string(), z.any())),
+              success: z.literal(false),
+            })
+            .meta({
+              ref: "BadRequestError",
+            }),
+        ),
+      },
+    },
+  },
+  404: {
+    description: "Not found",
+    content: {
+      "application/json": {
+        schema: resolver(Storage.NotFoundError.Schema),
+      },
+    },
+  },
+} as const
+
+function errors(...codes: number[]) {
+  return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
+}
+
 // not exposed to Stainless API
 export const WebGuiRoute = new Hono()
+  .get(
+    "/auth/methods",
+    describeRoute({
+      description: "Get auth methods for a provider",
+      operationId: "auth.methods",
+      responses: {
+        200: {
+          description: "Auth methods",
+          content: {
+            "application/json": {
+              schema: resolver(
+                z.array(
+                  z.object({
+                    label: z.string(),
+                    type: z.enum(["oauth", "api"]),
+                    prompts: z.array(z.any()).optional(),
+                  }),
+                ),
+              ),
+            },
+          },
+        },
+      },
+    }),
+    validator("query", z.object({ provider: z.string() })),
+    async (c) => {
+      const { provider } = c.req.valid("query")
+      const plugin = await import("../../plugin").then((m) =>
+        m.Plugin.list().then((x) => x.find((p) => p.auth?.provider === provider)),
+      )
+      if (!plugin || !plugin.auth) return c.json([])
+      return c.json(
+        plugin.auth.methods.map((m) => ({
+          label: m.label,
+          type: m.type,
+          prompts: m.prompts,
+        })),
+      )
+    },
+  )
+  .get(
+    "/auth/list",
+    describeRoute({
+      description: "List configured auth providers",
+      operationId: "auth.list",
+      responses: {
+        200: {
+          description: "List of configured auth providers",
+          content: {
+            "application/json": {
+              schema: resolver(z.record(z.string(), Auth.Info)),
+            },
+          },
+        },
+      },
+    }),
+    async (c) => {
+      const auths = await Auth.all()
+      return c.json(auths)
+    },
+  )
+  .post(
+    "/auth/set",
+    describeRoute({
+      description: "Set auth provider",
+      operationId: "auth.set",
+      responses: {
+        200: {
+          description: "Set successfully",
+          content: {
+            "application/json": {
+              schema: resolver(z.boolean()),
+            },
+          },
+        },
+      },
+    }),
+    validator("json", z.object({ provider: z.string(), value: z.any() })),
+    async (c) => {
+      const { provider, value } = c.req.valid("json")
+      await Auth.set(provider, value)
+      await Instance.disposeAll()
+      return c.json(true)
+    },
+  )
+  .post(
+    "/auth/remove",
+    describeRoute({
+      description: "Remove an auth provider",
+      operationId: "auth.remove",
+      responses: {
+        200: {
+          description: "Removed successfully",
+          content: {
+            "application/json": {
+              schema: resolver(z.boolean()),
+            },
+          },
+        },
+      },
+    }),
+    validator("json", z.object({ provider: z.string() })),
+    async (c) => {
+      const { provider } = c.req.valid("json")
+      await Auth.remove(provider)
+      await Instance.disposeAll()
+      return c.json(true)
+    },
+  )
+  .post(
+    "/auth/login/start",
+    describeRoute({
+      description: "Start auth flow",
+      operationId: "auth.login.start",
+      responses: {
+        200: {
+          description: "Auth started",
+          content: {
+            "application/json": {
+              schema: resolver(
+                z.object({
+                  id: z.string(),
+                  url: z.string().optional(),
+                  method: z.enum(["auto", "code"]),
+                }),
+              ),
+            },
+          },
+        },
+      },
+    }),
+    validator(
+      "json",
+      z.object({
+        provider: z.string(),
+        methodIndex: z.number(),
+        inputs: z.record(z.string(), z.string()),
+      }),
+    ),
+    async (c) => {
+      const { provider, methodIndex, inputs } = c.req.valid("json")
+      const plugin = await import("../../plugin").then((m) =>
+        m.Plugin.list().then((x) => x.find((p) => p.auth?.provider === provider)),
+      )
+      if (!plugin || !plugin.auth) throw new Error("Provider not found")
+
+      const method = plugin.auth.methods[methodIndex]
+      if (!method) throw new Error("Method not found")
+
+      if (method.type === "oauth") {
+        const authorize = await method.authorize(inputs)
+        const id = crypto.randomUUID()
+
+        if (authorize.method === "auto") {
+          // Start background polling/waiting
+          pendingAuths.set(id, { status: "pending" })
+          authorize
+            .callback()
+            .then(async (result) => {
+              if (result.type === "success") {
+                // Save credentials immediately like CLI does
+                const saveProvider = result.provider ?? provider
+                if ("refresh" in result) {
+                  const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
+                  await Auth.set(saveProvider, {
+                    type: "oauth",
+                    refresh,
+                    access,
+                    expires,
+                    ...extraFields,
+                  })
+                }
+                if ("key" in result) {
+                  await Auth.set(saveProvider, {
+                    type: "api",
+                    key: result.key,
+                  })
+                }
+                await Instance.disposeAll()
+                pendingAuths.set(id, { status: "success", result })
+              } else {
+                pendingAuths.set(id, { status: "failed", result })
+              }
+            })
+            .catch((err) => {
+              pendingAuths.set(id, { status: "failed", result: err })
+            })
+
+          return c.json({ id, url: authorize.url, method: "auto" as const })
+        }
+
+        if ((authorize as any).method === "code") {
+          pendingAuths.set(id, {
+            status: "pending",
+            callback: authorize.callback,
+          })
+          return c.json({ id, url: authorize.url, method: "code" as const })
+        }
+
+        throw new Error("Unsupported oauth method: " + authorize.method)
+      }
+
+      throw new Error("Only oauth supported for now via this endpoint")
+    },
+  )
+  .post(
+    "/auth/login/submit",
+    describeRoute({
+      description: "Submit auth code",
+      operationId: "auth.login.submit",
+      responses: {
+        200: {
+          description: "Code submitted",
+          content: {
+            "application/json": {
+              schema: resolver(z.boolean()),
+            },
+          },
+        },
+        ...errors(400, 404),
+      },
+    }),
+    validator(
+      "json",
+      z.object({
+        id: z.string(),
+        code: z.string(),
+      }),
+    ),
+    async (c) => {
+      const { id, code } = c.req.valid("json")
+      const session = pendingAuths.get(id)
+      if (!session) return c.json({ success: false, data: null, errors: [{ message: "Session not found" }] }, 404)
+      if (!session.callback)
+        return c.json({ success: false, data: null, errors: [{ message: "Session does not expect code" }] }, 400)
+
+      try {
+        const result = await session.callback(code)
+        if (result.type === "success") {
+          const saveProvider = result.provider
+          if ("refresh" in result) {
+            const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
+            await Auth.set(saveProvider, {
+              type: "oauth",
+              refresh,
+              access,
+              expires,
+              ...extraFields,
+            })
+          }
+          if ("key" in result) {
+            await Auth.set(saveProvider, {
+              type: "api",
+              key: result.key,
+            })
+          }
+          await Instance.disposeAll()
+          session.status = "success"
+          session.result = result
+        } else {
+          session.status = "failed"
+          session.result = result
+        }
+      } catch (err) {
+        session.status = "failed"
+        session.result = err
+      }
+
+      return c.json(true)
+    },
+  )
+  .get(
+    "/auth/login/status/:id",
+    describeRoute({
+      description: "Get auth login status",
+      operationId: "auth.login.status",
+      responses: {
+        200: {
+          description: "Auth status",
+          content: {
+            "application/json": {
+              schema: resolver(
+                z.object({
+                  status: z.enum(["pending", "success", "failed"]),
+                  result: z.any().optional(),
+                }),
+              ),
+            },
+          },
+        },
+        ...errors(404),
+      },
+    }),
+    async (c) => {
+      const id = c.req.param("id")
+      const session = pendingAuths.get(id)
+      if (!session) return c.json({ success: false, data: null, errors: [{ message: "Session not found" }] }, 404)
+      return c.json({ status: session.status, result: session.result })
+    },
+  )
+  .get(
+    "/config/providers",
+    describeRoute({
+      description: "List all providers",
+      operationId: "config.providers",
+      responses: {
+        200: {
+          description: "List of providers",
+          content: {
+            "application/json": {
+              schema: resolver(
+                z.object({
+                  providers: ModelsDev.Provider.array(),
+                  default: z.record(z.string(), z.string()),
+                }),
+              ),
+            },
+          },
+        },
+      },
+    }),
+    async (c) => {
+      // Get providers directly from updated cache after refresh
+      const database = await ModelsDev.get()
+      const activeProviders = await Provider.list().then((x) => mapValues(x, (item) => item.info))
+
+      // Merge active providers into the full list from database
+      // This ensures we have all available providers, but with updated info for active ones
+      const allProviders = { ...database, ...activeProviders }
+
+      return c.json({
+        providers: Object.values(allProviders),
+        default: mapValues(activeProviders, (item) => Provider.sort(Object.values(item.models))[0]?.id ?? ""),
+      })
+    },
+  )
   .get(
     "/state",
-    /*describeRoute({
+    describeRoute({
       description: "Get TUI state (theme, model, agent preferences)",
       operationId: "state.get",
       responses: {
@@ -22,13 +405,15 @@ export const WebGuiRoute = new Hono()
                 z
                   .object({
                     theme: z.string().optional(),
-                    agent_model: z.record(
-                      z.string(),
-                      z.object({
-                        provider_id: z.string(),
-                        model_id: z.string(),
-                      }),
-                    ).optional(),
+                    agent_model: z
+                      .record(
+                        z.string(),
+                        z.object({
+                          provider_id: z.string(),
+                          model_id: z.string(),
+                        }),
+                      )
+                      .optional(),
                     provider: z.string().optional(),
                     model: z.string().optional(),
                     agent: z.string().optional(),
@@ -58,7 +443,7 @@ export const WebGuiRoute = new Hono()
           },
         },
       },
-    }),*/
+    }),
     async (c) => {
       const state = await State.read()
       return c.json(state)
@@ -66,7 +451,7 @@ export const WebGuiRoute = new Hono()
   )
   .patch(
     "/state",
-    /*describeRoute({
+    describeRoute({
       description: "Update TUI state (merge with existing)",
       operationId: "state.update",
       responses: {
@@ -78,13 +463,15 @@ export const WebGuiRoute = new Hono()
                 z
                   .object({
                     theme: z.string().optional(),
-                    agent_model: z.record(
-                      z.string(),
-                      z.object({
-                        provider_id: z.string(),
-                        model_id: z.string(),
-                      }),
-                    ).optional(),
+                    agent_model: z
+                      .record(
+                        z.string(),
+                        z.object({
+                          provider_id: z.string(),
+                          model_id: z.string(),
+                        }),
+                      )
+                      .optional(),
                     provider: z.string().optional(),
                     model: z.string().optional(),
                     agent: z.string().optional(),
@@ -115,7 +502,7 @@ export const WebGuiRoute = new Hono()
         },
         ...errors(400),
       },
-    }),*/
+    }),
     validator("json", StatePatchSchema),
     async (c) => {
       const partial = c.req.valid("json")

+ 10 - 0
packages/opencode/webgui/src/components/MessageInput.tsx

@@ -38,6 +38,7 @@ import {
 import { toProjectRelative } from "../lib/path"
 import { extractPathsFromDrop } from "../lib/dnd"
 import { ConfirmModal } from "./ConfirmModal"
+import { useProviders } from "../state/ProvidersContext"
 
 interface MessageInputProps {
   sessionID: string | null
@@ -151,6 +152,7 @@ const MessageInputInner = forwardRef<
   const [lastFailedMessage, setLastFailedMessage] = useState<string | null>(null)
   const [isCompactConfirmOpen, setIsCompactConfirmOpen] = useState(false)
   const [isCompacting, setIsCompacting] = useState(false)
+  const [modelSelectorKey, setModelSelectorKey] = useState(0)
   const contentEditableRef = useRef<HTMLDivElement>(null)
   const fileInputRef = useRef<HTMLInputElement>(null)
   const containerRef = useRef<HTMLDivElement>(null)
@@ -167,6 +169,7 @@ const MessageInputInner = forwardRef<
     isVirtualSession,
     materializeSession,
   } = useSession()
+  const { providersDirty, clearProvidersDirty } = useProviders()
 
   const handleEditorChange = useCallback((editorState: EditorState) => {
     editorState.read(() => {
@@ -992,6 +995,12 @@ const MessageInputInner = forwardRef<
     }
   }, [])
 
+  useEffect(() => {
+    if (!providersDirty) return
+    setModelSelectorKey((value) => value + 1)
+    clearProvidersDirty()
+  }, [providersDirty, clearProvidersDirty])
+
   return (
     <>
       <footer className="border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 flex-shrink-0">
@@ -1049,6 +1058,7 @@ const MessageInputInner = forwardRef<
 
             {/* Model selector */}
             <ModelSelector
+              key={modelSelectorKey}
               selectedProviderId={selectedProviderId}
               selectedModelId={selectedModelId}
               onSelect={setSelectedModel}

+ 27 - 20
packages/opencode/webgui/src/components/SettingsPanel.tsx

@@ -6,6 +6,7 @@ import { GeneralTab } from "./settings/GeneralTab"
 import { ApiKeysTab } from "./settings/ApiKeysTab"
 import { ModelsTab } from "./settings/ModelsTab"
 import { AdvancedTab } from "./settings/AdvancedTab"
+import { useProviders } from "../state/ProvidersContext.tsx"
 
 interface SettingsPanelProps {
   isOpen: boolean
@@ -22,11 +23,13 @@ interface ProviderWithAuth extends Provider {
 export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
   const [activeTab, setActiveTab] = useState<TabType>("general")
   const [providers, setProviders] = useState<ProviderWithAuth[]>([])
+  const [configuredProviders, setConfiguredProviders] = useState<string[]>([])
   const [isLoading, setIsLoading] = useState(false)
   const [isSaving, setIsSaving] = useState(false)
   const [error, setError] = useState<string | null>(null)
   const [successMessage, setSuccessMessage] = useState<string | null>(null)
   const [showCloseConfirm, setShowCloseConfirm] = useState(false)
+  const { markProvidersDirty } = useProviders()
 
   // Form state
   const [formData, setFormData] = useState<Partial<Config>>({})
@@ -60,14 +63,18 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
         }
 
         // Fetch providers
-        const providersResponse = await sdk.config.providers()
-        if (providersResponse.error) {
+        const providersRes = await sdk.config.allProviders()
+        if (providersRes.error) {
           throw new Error("Failed to load providers")
         }
-        if (providersResponse.data) {
-          setProviders(providersResponse.data.providers)
+        if (providersRes.data) {
+          setProviders(providersRes.data.providers.sort((a, b) => a.name.localeCompare(b.name)))
         }
 
+        // Fetch configured providers
+        const authList = await sdk.auth.list()
+        setConfiguredProviders(Object.keys(authList))
+
         // Reset API keys to empty (they should be entered fresh)
         setApiKeys({})
       } catch (err) {
@@ -147,23 +154,17 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
       const apiKeyEntries = Object.entries(apiKeys).filter(([_, key]) => key && key.trim())
 
       for (const [providerID, key] of apiKeyEntries) {
-        const authResponse = await sdk.auth.set({
-          path: { id: providerID },
-          body: {
-            type: "api",
-            key: key.trim(),
-          },
+        await sdk.auth.set(providerID, {
+          type: "api",
+          key: key.trim(),
         })
-
-        if (authResponse.error) {
-          throw new Error(`Failed to save API key for ${providerID}`)
-        }
       }
 
       // Clear API keys after successful save
       setApiKeys({})
 
       setSuccessMessage("Settings saved successfully")
+      markProvidersDirty()
       setTimeout(() => {
         setSuccessMessage(null)
         onClose()
@@ -209,11 +210,10 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
                 <button
                   key={tab.id}
                   onClick={() => setActiveTab(tab.id)}
-                  className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
-                    activeTab === tab.id
-                      ? "border-blue-600 text-blue-600 dark:border-blue-500 dark:text-blue-500"
-                      : "border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
-                  }`}
+                  className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === tab.id
+                    ? "border-blue-600 text-blue-600 dark:border-blue-500 dark:text-blue-500"
+                    : "border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
+                    }`}
                 >
                   <span className="mr-1.5">{tab.icon}</span>
                   {tab.label}
@@ -239,6 +239,8 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
                 {activeTab === "api-keys" && (
                   <ApiKeysTab
                     providers={providers}
+                    configuredProviders={configuredProviders}
+                    setConfiguredProviders={setConfiguredProviders}
                     apiKeys={apiKeys}
                     setApiKeys={setApiKeys}
                     showApiKeys={showApiKeys}
@@ -247,7 +249,12 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
                 )}
 
                 {activeTab === "models" && (
-                  <ModelsTab formData={formData} setFormData={setFormData} providers={providers} />
+                  <ModelsTab
+                    formData={formData}
+                    setFormData={setFormData}
+                    providers={providers}
+                    configuredProviders={configuredProviders}
+                  />
                 )}
 
                 {activeTab === "advanced" && <AdvancedTab formData={formData} setFormData={setFormData} />}

+ 446 - 27
packages/opencode/webgui/src/components/settings/ApiKeysTab.tsx

@@ -1,50 +1,469 @@
 import type { Provider } from "@opencode-ai/sdk/client"
+import { useEffect, useState, useMemo, useRef } from "react"
+import { sdk } from "../../lib/api/sdkClient"
+import { ideBridge } from "../../lib/ideBridge"
+import { useDropdown } from "../../hooks/useDropdown"
+import { useProviders } from "../../state/ProvidersContext"
+import { ConfirmModal } from "../ConfirmModal"
 
 interface ApiKeysTabProps {
   providers: Provider[]
+  configuredProviders: string[]
+  setConfiguredProviders: (providers: string[]) => void
   apiKeys: Record<string, string>
   setApiKeys: (keys: Record<string, string>) => void
   showApiKeys: Record<string, boolean>
   setShowApiKeys: (show: Record<string, boolean>) => void
 }
 
-export function ApiKeysTab({ providers, apiKeys, setApiKeys, showApiKeys, setShowApiKeys }: ApiKeysTabProps) {
+interface AuthMethod {
+  label: string
+  type: "oauth" | "api"
+}
+
+export function ApiKeysTab({
+  providers,
+  configuredProviders,
+  setConfiguredProviders,
+  apiKeys,
+  setApiKeys,
+  showApiKeys,
+  setShowApiKeys,
+}: ApiKeysTabProps) {
+  const [methods, setMethods] = useState<Record<string, AuthMethod[]>>({})
+  const [loadingMethods, setLoadingMethods] = useState<Record<string, boolean>>({})
+  const [authStatus, setAuthStatus] = useState<Record<string, string>>({})
+  const pollIntervals = useRef<Record<string, ReturnType<typeof setInterval>>>({})
+  const [selectedProviderToAdd, setSelectedProviderToAdd] = useState<string>("")
+  const [manualCodeState, setManualCodeState] = useState<{ providerId: string; id: string } | null>(null)
+  const [manualCodeInput, setManualCodeInput] = useState("")
+  const { isOpen, searchTerm, setSearchTerm, dropdownRef, close, toggle } = useDropdown()
+  const [providerToDelete, setProviderToDelete] = useState<string | null>(null)
+  const [isDeleting, setIsDeleting] = useState(false)
+  const { markProvidersDirty } = useProviders()
+
+  // Filter providers to only show configured ones + the one currently being added
+  const displayedProviders = useMemo(() => {
+    const configured = new Set(configuredProviders)
+    return providers.filter((p) => configured.has(p.id) || p.id === selectedProviderToAdd)
+  }, [providers, configuredProviders, selectedProviderToAdd])
+
+  // Available providers for the dropdown (excluding already configured ones)
+  const availableProviders = useMemo(() => {
+    const configured = new Set(configuredProviders)
+    return providers.filter((p) => !configured.has(p.id))
+  }, [providers, configuredProviders])
+
+  // Filtered available providers based on search term
+  const filteredAvailableProviders = useMemo(() => {
+    if (!searchTerm) return availableProviders
+    const lower = searchTerm.toLowerCase()
+    return availableProviders.filter((p) => p.name.toLowerCase().includes(lower))
+  }, [availableProviders, searchTerm])
+
+  const handleSelectProvider = (providerId: string) => {
+    handleAddProvider(providerId)
+    close()
+  }
+
+  useEffect(() => {
+    const fetchMethods = async () => {
+      const newLoading: Record<string, boolean> = {}
+
+      // Set loading state
+      displayedProviders.forEach((p) => {
+        if (!methods[p.id]) {
+          newLoading[p.id] = true
+        }
+      })
+
+      if (Object.keys(newLoading).length === 0) return
+
+      setLoadingMethods((prev) => ({ ...prev, ...newLoading }))
+
+      // Fetch methods in parallel
+      await Promise.all(
+        displayedProviders.map(async (provider) => {
+          if (methods[provider.id]) return
+          try {
+            const m = await sdk.auth.methods(provider.id)
+            if (m && m.length > 0) {
+              setMethods((prev) => ({ ...prev, [provider.id]: m }))
+            }
+          } catch (e) {
+            console.error(`Failed to fetch methods for ${provider.id}`, e)
+          } finally {
+            setLoadingMethods((prev) => ({ ...prev, [provider.id]: false }))
+          }
+        }),
+      )
+    }
+
+    if (displayedProviders.length > 0) {
+      fetchMethods()
+    }
+  }, [displayedProviders, methods])
+
+  const handleOAuthLogin = async (providerId: string, methodIndex: number) => {
+    try {
+      setAuthStatus((prev) => ({ ...prev, [providerId]: "Initializing..." }))
+      const { id, url, method } = await sdk.auth.start(providerId, methodIndex, {})
+
+      if (url) {
+        window.open(url, "_blank")
+        ideBridge.send({ type: "openUrl", payload: { url } })
+      }
+
+      if (method === "code") {
+        setAuthStatus((prev) => ({ ...prev, [providerId]: "Waiting for code..." }))
+        setManualCodeState({ providerId, id })
+        setManualCodeInput("")
+        return
+      }
+
+      setAuthStatus((prev) => ({ ...prev, [providerId]: "Waiting for browser..." }))
+
+      // Poll
+      const poll = setInterval(async () => {
+        try {
+          const status = await sdk.auth.status(id)
+          if (status.status === "success") {
+            clearInterval(poll)
+            delete pollIntervals.current[providerId]
+            setAuthStatus((prev) => ({ ...prev, [providerId]: "Connected!" }))
+            // Add to configured providers list if not already there
+            if (!configuredProviders.includes(providerId)) {
+              setConfiguredProviders([...configuredProviders, providerId])
+            }
+            // Clear temporary selection if it was this provider
+            if (selectedProviderToAdd === providerId) {
+              setSelectedProviderToAdd("")
+            }
+            setTimeout(() => setAuthStatus((prev) => ({ ...prev, [providerId]: "" })), 3000)
+            markProvidersDirty()
+          } else if (status.status === "failed") {
+            clearInterval(poll)
+            delete pollIntervals.current[providerId]
+            setAuthStatus((prev) => ({
+              ...prev,
+              [providerId]: "Failed: " + (status.result?.message || "Unknown error"),
+            }))
+          }
+        } catch (e) {
+          clearInterval(poll)
+          delete pollIntervals.current[providerId]
+          console.error("Error polling OAuth status:", e)
+          setAuthStatus((prev) => ({ ...prev, [providerId]: "Error polling status" }))
+        }
+      }, 1000)
+      pollIntervals.current[providerId] = poll
+
+      // Timeout after 2 mins
+      setTimeout(() => {
+        if (pollIntervals.current[providerId] === poll) {
+          clearInterval(poll)
+          delete pollIntervals.current[providerId]
+          setAuthStatus((prev) => {
+            if (prev[providerId]?.startsWith("Waiting")) return { ...prev, [providerId]: "Timed out" }
+            return prev
+          })
+        }
+      }, 120000)
+    } catch (e) {
+      setAuthStatus((prev) => ({ ...prev, [providerId]: "Error starting login" }))
+      console.error(e)
+    }
+  }
+
+  const handleCancel = (providerId: string) => {
+    if (pollIntervals.current[providerId]) {
+      clearInterval(pollIntervals.current[providerId])
+      delete pollIntervals.current[providerId]
+    }
+    setAuthStatus((prev) => ({ ...prev, [providerId]: "" }))
+    if (manualCodeState?.providerId === providerId) {
+      setManualCodeState(null)
+      setManualCodeInput("")
+    }
+  }
+
+  const handleManualCodeSubmit = async () => {
+    if (!manualCodeState || !manualCodeInput) return
+
+    const { providerId, id } = manualCodeState
+    try {
+      setAuthStatus((prev) => ({ ...prev, [providerId]: "Verifying code..." }))
+      await sdk.auth.submit(id, manualCodeInput)
+
+      // Check status immediately
+      const status = await sdk.auth.status(id)
+      if (status.status === "success") {
+        setAuthStatus((prev) => ({ ...prev, [providerId]: "Connected!" }))
+        if (!configuredProviders.includes(providerId)) {
+          setConfiguredProviders([...configuredProviders, providerId])
+        }
+        if (selectedProviderToAdd === providerId) {
+          setSelectedProviderToAdd("")
+        }
+        setManualCodeState(null)
+        setManualCodeInput("")
+        setTimeout(() => setAuthStatus((prev) => ({ ...prev, [providerId]: "" })), 3000)
+        markProvidersDirty()
+      } else {
+        setAuthStatus((prev) => ({
+          ...prev,
+          [providerId]: "Failed: " + (status.result?.message || "Invalid code"),
+        }))
+      }
+    } catch (e) {
+      console.error("Error submitting code:", e)
+      setAuthStatus((prev) => ({ ...prev, [providerId]: "Error submitting code" }))
+    }
+  }
+
+  const handleAddProvider = (providerId: string) => {
+    if (!providerId) return
+    setSelectedProviderToAdd(providerId)
+    // Optimistically add to displayed list via selectedProviderToAdd state
+    // It will be permanently added to configuredProviders when saved (API key) or logged in (OAuth)
+  }
+
+  const handleDeleteProvider = (providerId: string) => {
+    setProviderToDelete(providerId)
+  }
+
+  const confirmDeleteProvider = async () => {
+    if (!providerToDelete) return
+
+    setIsDeleting(true)
+    try {
+      await sdk.auth.remove(providerToDelete)
+      markProvidersDirty()
+      setConfiguredProviders(configuredProviders.filter((id) => id !== providerToDelete))
+      if (selectedProviderToAdd === providerToDelete) {
+        setSelectedProviderToAdd("")
+      }
+      // Also clear any pending API key input
+      const newApiKeys = { ...apiKeys }
+      delete newApiKeys[providerToDelete]
+      setApiKeys(newApiKeys)
+    } catch (e) {
+      console.error("Failed to remove provider", e)
+      alert("Failed to remove provider")
+    } finally {
+      setIsDeleting(false)
+      setProviderToDelete(null)
+    }
+  }
+
   return (
     <div className="space-y-4">
-      <p className="text-sm text-gray-600 dark:text-gray-400">
-        Configure API keys for AI providers. Keys are stored securely.
-      </p>
+      <div className="flex items-center justify-between mb-4">
+        <p className="text-sm text-gray-600 dark:text-gray-400">
+          Configure API keys or login to AI providers. Keys are stored securely.
+        </p>
+      </div>
 
-      {providers.length === 0 ? (
-        <div className="text-sm text-gray-500 dark:text-gray-400">No providers available</div>
-      ) : (
-        providers.map((provider) => (
-          <div key={provider.id} className="border border-gray-200 dark:border-gray-700 rounded p-3">
-            <div className="flex items-center justify-between mb-2">
-              <label className="text-sm font-medium text-gray-700 dark:text-gray-300">{provider.name}</label>
-              {provider.env.length > 0 && (
-                <span className="text-xs text-gray-500 dark:text-gray-400">{provider.env[0]}</span>
-              )}
-            </div>
-            <div className="flex gap-2">
+      {/* Add Provider Dropdown */}
+      <div className="relative mb-6" ref={dropdownRef}>
+        <button
+          onClick={toggle}
+          className="w-full flex items-center justify-between px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
+        >
+          <span>Add a provider...</span>
+          <svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+          </svg>
+        </button>
+
+        {isOpen && (
+          <div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 max-h-60 overflow-hidden flex flex-col">
+            <div className="p-2 border-b border-gray-200 dark:border-gray-700">
               <input
-                type={showApiKeys[provider.id] ? "text" : "password"}
-                value={apiKeys[provider.id] || ""}
-                onChange={(e) => setApiKeys({ ...apiKeys, [provider.id]: e.target.value })}
-                placeholder={`Enter ${provider.name} API key`}
-                className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
+                type="text"
+                value={searchTerm}
+                onChange={(e) => setSearchTerm(e.target.value)}
+                placeholder="Search providers..."
+                className="w-full px-2 py-1 text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
+                autoFocus
               />
+            </div>
+            <div className="overflow-y-auto flex-1">
+              {filteredAvailableProviders.length === 0 ? (
+                <div className="p-3 text-sm text-gray-500 dark:text-gray-400 text-center">
+                  No providers found
+                </div>
+              ) : (
+                filteredAvailableProviders.map((p) => (
+                  <button
+                    key={p.id}
+                    onClick={() => handleSelectProvider(p.id)}
+                    className="w-full px-3 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-900 dark:text-gray-100"
+                  >
+                    {p.name}
+                  </button>
+                ))
+              )}
+            </div>
+          </div>
+        )}
+      </div>
+
+      {displayedProviders.length === 0 ? (
+        <div className="text-sm text-gray-500 dark:text-gray-400 text-center py-8 border border-dashed border-gray-300 dark:border-gray-700 rounded">
+          No providers configured. Add one above.
+        </div>
+      ) : (
+        displayedProviders.map((provider) => {
+          const providerMethods = methods[provider.id] || []
+          const oauthMethodIndex = providerMethods.findIndex((m) => m.type === "oauth")
+          const hasOAuth = oauthMethodIndex !== -1
+          const isLoading = loadingMethods[provider.id]
+          const isTemporary = !configuredProviders.includes(provider.id)
+
+          return (
+            <div key={provider.id} className="border border-gray-200 dark:border-gray-700 rounded p-3 relative group">
               <button
-                onClick={() => setShowApiKeys({ ...showApiKeys, [provider.id]: !showApiKeys[provider.id] })}
-                className="px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
-                title={showApiKeys[provider.id] ? "Hide" : "Show"}
+                onClick={() => handleDeleteProvider(provider.id)}
+                className="absolute top-2 right-2 text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
+                title="Remove provider"
               >
-                {showApiKeys[provider.id] ? "👁️" : "👁️‍🗨️"}
+                <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+                </svg>
               </button>
+
+              <div className="flex items-center justify-between mb-2 pr-6">
+                <div className="flex items-center gap-2">
+                  <label className="text-sm font-medium text-gray-700 dark:text-gray-300">{provider.name}</label>
+                  {isTemporary && (
+                    <span className="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-1.5 py-0.5 rounded">
+                      New
+                    </span>
+                  )}
+                </div>
+                {provider.env.length > 0 && (
+                  <span className="text-xs text-gray-500 dark:text-gray-400">{provider.env[0]}</span>
+                )}
+              </div>
+
+              <div className="space-y-2">
+                {isLoading && <div className="text-xs text-gray-400">Loading auth methods...</div>}
+
+                {hasOAuth && (
+                  <div className="flex flex-col gap-2">
+                    <div className="flex items-center gap-2">
+                      <button
+                        onClick={() => handleOAuthLogin(provider.id, oauthMethodIndex)}
+                        disabled={!!authStatus[provider.id] && authStatus[provider.id] !== "Waiting for code..."}
+                        className="px-4 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded text-sm font-medium hover:bg-gray-800 dark:hover:bg-gray-200 transition-colors disabled:opacity-50"
+                      >
+                        {providerMethods[oauthMethodIndex].label || `Login with ${provider.name}`}
+                      </button>
+                      {authStatus[provider.id] && (
+                        <>
+                          <span className="text-sm text-blue-600 dark:text-blue-400 animate-pulse">
+                            {authStatus[provider.id]}
+                          </span>
+                          {(authStatus[provider.id].startsWith("Waiting") ||
+                            authStatus[provider.id] === "Initializing...") &&
+                            !manualCodeState && (
+                              <button
+                                onClick={() => handleCancel(provider.id)}
+                                className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400"
+                              >
+                                Cancel
+                              </button>
+                            )}
+                        </>
+                      )}
+                    </div>
+
+                    {manualCodeState?.providerId === provider.id && (
+                      <div className="flex gap-2 mt-2">
+                        <input
+                          type="text"
+                          value={manualCodeInput}
+                          onChange={(e) => setManualCodeInput(e.target.value)}
+                          placeholder="Paste authorization code here"
+                          className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
+                        />
+                        <button
+                          onClick={handleManualCodeSubmit}
+                          disabled={!manualCodeInput}
+                          className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
+                        >
+                          Submit
+                        </button>
+                        <button
+                          onClick={() => {
+                            setManualCodeState(null)
+                            setManualCodeInput("")
+                            setAuthStatus((prev) => ({ ...prev, [provider.id]: "" }))
+                          }}
+                          className="px-3 py-2 border border-gray-300 dark:border-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
+                        >
+                          Cancel
+                        </button>
+                      </div>
+                    )}
+                  </div>
+                )}
+
+                {/* Show API Key input if no OAuth, or as alternative */}
+                {(!hasOAuth || providerMethods.some((m) => m.type === "api")) && !isLoading && (
+                  <>
+                    {hasOAuth && (
+                      <div className="relative my-3">
+                        <div className="absolute inset-0 flex items-center">
+                          <span className="w-full border-t border-gray-200 dark:border-gray-700" />
+                        </div>
+                        <div className="relative flex justify-center text-xs uppercase">
+                          <span className="bg-white dark:bg-gray-900 px-2 text-gray-500">Or use API Key</span>
+                        </div>
+                      </div>
+                    )}
+                    <div className="flex gap-2">
+                      <input
+                        type={showApiKeys[provider.id] ? "text" : "password"}
+                        value={apiKeys[provider.id] || ""}
+                        onChange={(e) => {
+                          setApiKeys({ ...apiKeys, [provider.id]: e.target.value })
+                          // If user starts typing, ensure it's treated as being added (though save is required to persist)
+                          if (!configuredProviders.includes(provider.id)) {
+                            // We don't add to configuredProviders yet, that happens on save
+                            // But we keep it in selectedProviderToAdd so it stays visible
+                          }
+                        }}
+                        placeholder={`Enter ${provider.name} API key`}
+                        className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
+                      />
+                      <button
+                        onClick={() => setShowApiKeys({ ...showApiKeys, [provider.id]: !showApiKeys[provider.id] })}
+                        className="px-3 py-2 border border-gray-300 dark:border-gray-700 rounded bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
+                        title={showApiKeys[provider.id] ? "Hide" : "Show"}
+                      >
+                        {showApiKeys[provider.id] ? "👁️" : "👁️‍🗨️"}
+                      </button>
+                    </div>
+                  </>
+                )}
+              </div>
             </div>
-          </div>
-        ))
+          )
+        })
       )}
+      <ConfirmModal
+        isOpen={!!providerToDelete}
+        onClose={() => setProviderToDelete(null)}
+        onConfirm={confirmDeleteProvider}
+        title="Remove Provider"
+        message={`Are you sure you want to remove ${providerToDelete}? This will remove any stored authentication tokens.`}
+        confirmText="Remove"
+        cancelText="Cancel"
+        variant="danger"
+        isLoading={isDeleting}
+      />
     </div>
   )
 }

+ 8 - 2
packages/opencode/webgui/src/components/settings/ModelsTab.tsx

@@ -4,9 +4,12 @@ interface ModelsTabProps {
   formData: Partial<Config>
   setFormData: (data: Partial<Config>) => void
   providers: Provider[]
+  configuredProviders: string[]
 }
 
-export function ModelsTab({ formData, setFormData, providers }: ModelsTabProps) {
+export function ModelsTab({ formData, setFormData, providers, configuredProviders }: ModelsTabProps) {
+  const displayedProviders = providers.filter((p) => configuredProviders.includes(p.id))
+
   return (
     <div className="space-y-4">
       <div>
@@ -38,7 +41,7 @@ export function ModelsTab({ formData, setFormData, providers }: ModelsTabProps)
       <div>
         <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Disabled Providers</label>
         <div className="space-y-2">
-          {providers.map((provider) => (
+          {displayedProviders.map((provider) => (
             <label key={provider.id} className="flex items-center space-x-2">
               <input
                 type="checkbox"
@@ -56,6 +59,9 @@ export function ModelsTab({ formData, setFormData, providers }: ModelsTabProps)
               <span className="text-sm text-gray-700 dark:text-gray-300">{provider.name}</span>
             </label>
           ))}
+          {displayedProviders.length === 0 && (
+            <p className="text-sm text-gray-500 dark:text-gray-400 italic">No configured providers found.</p>
+          )}
         </div>
       </div>
     </div>

+ 84 - 1
packages/opencode/webgui/src/lib/api/sdkClient.ts

@@ -3,7 +3,7 @@
  * Configured to connect to the OpenCode server at the default location
  */
 
-import { createOpencodeClient } from "@opencode-ai/sdk/client"
+import { createOpencodeClient, type Provider } from "@opencode-ai/sdk/client"
 
 // Create a single SDK client instance with relative baseUrl
 // The server runs on the same origin, so we use '/' for relative requests
@@ -31,12 +31,95 @@ interface StateResponse {
   show_thinking_blocks?: boolean
 }
 
+interface ProvidersResponse {
+  providers: Provider[]
+  default: Record<string, string>
+}
+
 /**
  * Extended SDK client with state management methods
  * TODO: Remove once SDK is regenerated with Stainless
  */
 export const sdk = {
   ...baseClient,
+  config: {
+    get: baseClient.config.get.bind(baseClient.config),
+    update: baseClient.config.update.bind(baseClient.config),
+    providers: baseClient.config.providers.bind(baseClient.config),
+    allProviders: async () => {
+      try {
+        const response = await fetch("/app/api/config/providers", {
+          method: "GET",
+          headers: { "Content-Type": "application/json" },
+        })
+
+        if (!response.ok) {
+          return { error: { message: "Failed to load providers" }, data: null as ProvidersResponse | null }
+        }
+
+        const data = (await response.json()) as ProvidersResponse
+        return { data, error: null as { message: string } | null }
+      } catch (error) {
+        return {
+          error: { message: error instanceof Error ? error.message : "Unknown error" },
+          data: null as ProvidersResponse | null,
+        }
+      }
+    },
+  },
+  auth: {
+    set: async (provider: string, value: any) => {
+      const res = await fetch("/app/api/auth/set", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ provider, value }),
+      })
+      if (!res.ok) throw new Error(await res.text())
+    },
+    list: async () => {
+      const res = await fetch("/app/api/auth/list")
+      return res.json() as Promise<Record<string, any>>
+    },
+    remove: async (provider: string) => {
+      await fetch("/app/api/auth/remove", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ provider }),
+      })
+    },
+    methods: async (provider: string) => {
+      const res = await fetch(`/app/api/auth/methods?provider=${provider}`)
+      return res.json() as Promise<
+        Array<{
+          label: string
+          type: "oauth" | "api"
+          prompts?: any[]
+        }>
+      >
+    },
+    start: async (provider: string, methodIndex: number, inputs: any) => {
+      const res = await fetch("/app/api/auth/login/start", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ provider, methodIndex, inputs }),
+      })
+      if (!res.ok) throw new Error(await res.text())
+      return res.json() as Promise<{ id: string; url?: string; method: "auto" | "code" }>
+    },
+    submit: async (id: string, code: string) => {
+      const res = await fetch("/app/api/auth/login/submit", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ id, code }),
+      })
+      if (!res.ok) throw new Error(await res.text())
+      return res.json() as Promise<boolean>
+    },
+    status: async (id: string) => {
+      const res = await fetch(`/app/api/auth/login/status/${id}`)
+      return res.json() as Promise<{ status: "pending" | "success" | "failed"; result?: any }>
+    },
+  },
   permissions: {
     respond: async (options: {
       path: { id: string; permissionID: string }

+ 4 - 1
packages/opencode/webgui/src/main.tsx

@@ -8,6 +8,7 @@ import { ToastProvider } from "./state/ToastContext.tsx"
 import { ErrorBoundary } from "./components/ErrorBoundary.tsx"
 import { ProjectProvider } from "./state/ProjectContext.tsx"
 import { IdeBridgeProvider } from "./state/IdeBridgeContext"
+import { ProvidersProvider } from "./state/ProvidersContext"
 import { initGlobalDnD } from "./lib/dnd"
 
 ideBridge.init()
@@ -20,7 +21,9 @@ createRoot(document.getElementById("root")!).render(
         <SessionProvider>
           <ToastProvider>
             <IdeBridgeProvider>
-              <App />
+              <ProvidersProvider>
+                <App />
+              </ProvidersProvider>
             </IdeBridgeProvider>
           </ToastProvider>
         </SessionProvider>

+ 32 - 0
packages/opencode/webgui/src/state/ProvidersContext.tsx

@@ -0,0 +1,32 @@
+import { createContext, useContext, useState, type ReactNode } from "react"
+
+interface ProvidersContextState {
+  providersDirty: boolean
+  markProvidersDirty: () => void
+  clearProvidersDirty: () => void
+}
+
+const ProvidersContext = createContext<ProvidersContextState | null>(null)
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useProviders() {
+  const ctx = useContext(ProvidersContext)
+  if (!ctx) throw new Error("useProviders must be used within a ProvidersProvider")
+  return ctx
+}
+
+interface ProvidersProviderProps {
+  children: ReactNode
+}
+
+export function ProvidersProvider({ children }: ProvidersProviderProps) {
+  const [dirty, setDirty] = useState(false)
+
+  const value: ProvidersContextState = {
+    providersDirty: dirty,
+    markProvidersDirty: () => setDirty(true),
+    clearProvidersDirty: () => setDirty(false),
+  }
+
+  return <ProvidersContext.Provider value={value}>{children}</ProvidersContext.Provider>
+}