Przeglądaj źródła

fix(digitalocean): use OAuth token directly for inference instead of creating MAK (#28897)

Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Musa 2 miesięcy temu
rodzic
commit
0448a30821

+ 7 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

@@ -262,6 +262,13 @@ function AutoMethod(props: AutoMethodProps) {
       method: props.index,
       method: props.index,
     })
     })
     if (result.error) {
     if (result.error) {
+      toast.show({
+        variant: "error",
+        message:
+          "name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed"
+            ? "OAuth authorization failed. Try /connect again."
+            : JSON.stringify(result.error),
+      })
       dialog.clear()
       dialog.clear()
       return
       return
     }
     }

+ 17 - 37
packages/opencode/src/plugin/digitalocean.ts

@@ -3,18 +3,20 @@ import type { Model } from "@opencode-ai/sdk/v2"
 import * as Log from "@opencode-ai/core/util/log"
 import * as Log from "@opencode-ai/core/util/log"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { createServer } from "http"
 import { createServer } from "http"
+import open from "open"
 
 
 const log = Log.create({ service: "plugin.digitalocean" })
 const log = Log.create({ service: "plugin.digitalocean" })
 
 
 const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
 const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
 const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
 const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
 const DO_API_BASE = "https://api.digitalocean.com"
 const DO_API_BASE = "https://api.digitalocean.com"
+const DO_GENAI_API = `${DO_API_BASE}/v2/gen-ai`
 const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1"
 const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1"
 const OAUTH_PORT = 1456
 const OAUTH_PORT = 1456
 const OAUTH_REDIRECT_PATH = "/auth/callback"
 const OAUTH_REDIRECT_PATH = "/auth/callback"
 const OAUTH_TOKEN_PATH = "/auth/token"
 const OAUTH_TOKEN_PATH = "/auth/token"
 const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000
 const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000
-const MAK_NAME_PREFIX = "opencode-oauth"
+const OAUTH_SCOPES = "genai:read inference:query"
 
 
 interface ImplicitTokenPayload {
 interface ImplicitTokenPayload {
   access_token: string
   access_token: string
@@ -28,12 +30,6 @@ interface PendingOAuth {
   reject: (error: Error) => void
   reject: (error: Error) => void
 }
 }
 
 
-interface ApiKeyInfo {
-  uuid: string
-  name: string
-  secret_key: string
-}
-
 interface RouterEntry {
 interface RouterEntry {
   name: string
   name: string
   uuid?: string
   uuid?: string
@@ -59,7 +55,7 @@ function buildAuthorizeUrl(state: string): string {
     response_type: "token",
     response_type: "token",
     client_id: DO_OAUTH_CLIENT_ID,
     client_id: DO_OAUTH_CLIENT_ID,
     redirect_uri: redirectUri(),
     redirect_uri: redirectUri(),
-    scope: "genai:create genai:read",
+    scope: OAUTH_SCOPES,
     state,
     state,
   })
   })
   return `${DO_AUTHORIZE_URL}?${params.toString()}`
   return `${DO_AUTHORIZE_URL}?${params.toString()}`
@@ -91,15 +87,20 @@ const HTML_CALLBACK = `<!doctype html>
         const errorDescription = params.get("error_description") || search.get("error_description")
         const errorDescription = params.get("error_description") || search.get("error_description")
         const titleEl = document.getElementById("title")
         const titleEl = document.getElementById("title")
         const msgEl = document.getElementById("msg")
         const msgEl = document.getElementById("msg")
+        const tokenUrl = new URL(${JSON.stringify(OAUTH_TOKEN_PATH)}, window.location.origin).href
         try {
         try {
           const body = error
           const body = error
             ? { error, error_description: errorDescription || "" }
             ? { error, error_description: errorDescription || "" }
             : { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
             : { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
-          await fetch(${JSON.stringify(OAUTH_TOKEN_PATH)}, {
+          const res = await fetch(tokenUrl, {
             method: "POST",
             method: "POST",
             headers: { "Content-Type": "application/json" },
             headers: { "Content-Type": "application/json" },
             body: JSON.stringify(body),
             body: JSON.stringify(body),
           })
           })
+          if (!res.ok) {
+            const detail = await res.text().catch(function () { return "" })
+            throw new Error(detail || ("callback failed (" + res.status + ")"))
+          }
           if (error) {
           if (error) {
             titleEl.textContent = "Authorization Failed"
             titleEl.textContent = "Authorization Failed"
             msgEl.textContent = errorDescription || error
             msgEl.textContent = errorDescription || error
@@ -225,31 +226,10 @@ function waitForOAuthCallback(state: string): Promise<ImplicitTokenPayload> {
   })
   })
 }
 }
 
 
-async function createModelAccessKey(bearer: string): Promise<ApiKeyInfo> {
-  // Suffix-on-collision strategy keeps re-`/connect` non-destructive.
-  const name = `${MAK_NAME_PREFIX}-${Math.floor(Date.now() / 1000)}`
-  const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/api_keys`, {
-    method: "POST",
-    headers: {
-      Authorization: `Bearer ${bearer}`,
-      "Content-Type": "application/json",
-      "User-Agent": `opencode/${InstallationVersion}`,
-    },
-    body: JSON.stringify({ name }),
-  })
-  if (!res.ok) {
-    const body = await res.text().catch(() => "")
-    throw new Error(`Failed to create Model Access Key (${res.status}): ${body}`)
-  }
-  const data = (await res.json()) as { api_key_info?: ApiKeyInfo }
-  if (!data.api_key_info?.secret_key) throw new Error("Model Access Key response missing secret_key")
-  return data.api_key_info
-}
-
 async function listRouters(
 async function listRouters(
   bearer: string,
   bearer: string,
 ): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> {
 ): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> {
-  const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, {
+  const res = await fetch(`${DO_GENAI_API}/models/routers`, {
     headers: {
     headers: {
       Authorization: `Bearer ${bearer}`,
       Authorization: `Bearer ${bearer}`,
       Accept: "application/json",
       Accept: "application/json",
@@ -362,15 +342,16 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
             await startOAuthServer()
             await startOAuthServer()
             const state = generateState()
             const state = generateState()
             const callbackPromise = waitForOAuthCallback(state)
             const callbackPromise = waitForOAuthCallback(state)
+            const url = buildAuthorizeUrl(state)
+            await open(url).catch(() => undefined)
             return {
             return {
-              url: buildAuthorizeUrl(state),
+              url,
               instructions:
               instructions:
-                "Sign in to DigitalOcean in your browser. OpenCode will create a Model Access Key named opencode-oauth-* and load your Inference Routers. Re-run /connect to refresh routers later.",
+                "Sign in to DigitalOcean in your browser. OpenCode will use your DigitalOcean API token directly for inference and load your Inference Routers. Re-run /connect to refresh routers later.",
               method: "auto" as const,
               method: "auto" as const,
               async callback() {
               async callback() {
                 try {
                 try {
                   const tokens = await callbackPromise
                   const tokens = await callbackPromise
-                  const apiKeyInfo = await createModelAccessKey(tokens.access_token)
                   const routerResult = await listRouters(tokens.access_token)
                   const routerResult = await listRouters(tokens.access_token)
                   const routers = routerResult.ok ? routerResult.routers : []
                   const routers = routerResult.ok ? routerResult.routers : []
                   if (!routerResult.ok) {
                   if (!routerResult.ok) {
@@ -379,12 +360,11 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
                   return {
                   return {
                     type: "success" as const,
                     type: "success" as const,
                     provider: "digitalocean",
                     provider: "digitalocean",
-                    key: apiKeyInfo.secret_key,
+                    key: tokens.access_token,
                     metadata: {
                     metadata: {
-                      mak_uuid: apiKeyInfo.uuid,
-                      mak_name: apiKeyInfo.name,
                       oauth_access: tokens.access_token,
                       oauth_access: tokens.access_token,
                       oauth_expires: String(Date.now() + tokens.expires_in * 1000),
                       oauth_expires: String(Date.now() + tokens.expires_in * 1000),
+                      oauth_scopes: OAUTH_SCOPES,
                       routers: JSON.stringify(
                       routers: JSON.stringify(
                         routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })),
                         routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })),
                       ),
                       ),

+ 6 - 2
packages/web/src/content/docs/providers.mdx

@@ -727,7 +727,7 @@ DigitalOcean's [Inference Engine](https://docs.digitalocean.com/products/inferen
 
 
 OpenCode supports two authentication methods:
 OpenCode supports two authentication methods:
 
 
-- **OAuth (Recommended)** — Sign in to your DigitalOcean account; OpenCode auto-creates a Model Access Key and discovers your available Models & Inference Routers.
+- **OAuth (Recommended)** — Sign in to your DigitalOcean account; OpenCode uses your DigitalOcean API token directly for inference and discovers your Inference Routers.
 - **Model Access Key** — Paste an existing key from the DigitalOcean console.
 - **Model Access Key** — Paste an existing key from the DigitalOcean console.
 
 
 #### OAuth (Recommended)
 #### OAuth (Recommended)
@@ -751,7 +751,11 @@ OpenCode supports two authentication methods:
 3. Your browser opens to authorize OpenCode. Sign in and approve.
 3. Your browser opens to authorize OpenCode. Sign in and approve.
 
 
    :::note
    :::note
-   OpenCode creates a Model Access Key named `opencode-oauth-<timestamp>` in your DigitalOcean account. You can rotate or revoke it from the **Model Access Keys** page in the "Manage" section of the DigitalOcean console under Inference.
+   OpenCode requests `genai:read` and `inference:query` OAuth scopes. Your DigitalOcean API token is used directly for inference — no separate Model Access Key is created.
+   :::
+
+   :::note
+   Inference Routers only appear in the model picker after OAuth. Pasting a Model Access Key manually does not discover routers.
    :::
    :::
 
 
 4. Run the `/models` command. Your Inference Routers appear as the format `router:` in the model selection.
 4. Run the `/models` command. Your Inference Routers appear as the format `router:` in the model selection.