diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 2caa67b559..874afa05b4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -262,6 +262,13 @@ function AutoMethod(props: AutoMethodProps) { method: props.index, }) 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() return } diff --git a/packages/opencode/src/plugin/digitalocean.ts b/packages/opencode/src/plugin/digitalocean.ts index 19d1364875..d04437a4ea 100644 --- a/packages/opencode/src/plugin/digitalocean.ts +++ b/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 { InstallationVersion } from "@opencode-ai/core/installation/version" import { createServer } from "http" +import open from "open" const log = Log.create({ service: "plugin.digitalocean" }) const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82" const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize" 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 OAUTH_PORT = 1456 const OAUTH_REDIRECT_PATH = "/auth/callback" const OAUTH_TOKEN_PATH = "/auth/token" const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000 -const MAK_NAME_PREFIX = "opencode-oauth" +const OAUTH_SCOPES = "genai:read inference:query" interface ImplicitTokenPayload { access_token: string @@ -28,12 +30,6 @@ interface PendingOAuth { reject: (error: Error) => void } -interface ApiKeyInfo { - uuid: string - name: string - secret_key: string -} - interface RouterEntry { name: string uuid?: string @@ -59,7 +55,7 @@ function buildAuthorizeUrl(state: string): string { response_type: "token", client_id: DO_OAUTH_CLIENT_ID, redirect_uri: redirectUri(), - scope: "genai:create genai:read", + scope: OAUTH_SCOPES, state, }) return `${DO_AUTHORIZE_URL}?${params.toString()}` @@ -91,15 +87,20 @@ const HTML_CALLBACK = ` const errorDescription = params.get("error_description") || search.get("error_description") const titleEl = document.getElementById("title") const msgEl = document.getElementById("msg") + const tokenUrl = new URL(${JSON.stringify(OAUTH_TOKEN_PATH)}, window.location.origin).href try { const body = error ? { error, error_description: errorDescription || "" } : { 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", headers: { "Content-Type": "application/json" }, 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) { titleEl.textContent = "Authorization Failed" msgEl.textContent = errorDescription || error @@ -225,31 +226,10 @@ function waitForOAuthCallback(state: string): Promise { }) } -async function createModelAccessKey(bearer: string): Promise { - // 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( bearer: string, ): 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: { Authorization: `Bearer ${bearer}`, Accept: "application/json", @@ -362,15 +342,16 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise await startOAuthServer() const state = generateState() const callbackPromise = waitForOAuthCallback(state) + const url = buildAuthorizeUrl(state) + await open(url).catch(() => undefined) return { - url: buildAuthorizeUrl(state), + url, 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, async callback() { try { const tokens = await callbackPromise - const apiKeyInfo = await createModelAccessKey(tokens.access_token) const routerResult = await listRouters(tokens.access_token) const routers = routerResult.ok ? routerResult.routers : [] if (!routerResult.ok) { @@ -379,12 +360,11 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise return { type: "success" as const, provider: "digitalocean", - key: apiKeyInfo.secret_key, + key: tokens.access_token, metadata: { - mak_uuid: apiKeyInfo.uuid, - mak_name: apiKeyInfo.name, oauth_access: tokens.access_token, oauth_expires: String(Date.now() + tokens.expires_in * 1000), + oauth_scopes: OAUTH_SCOPES, routers: JSON.stringify( routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })), ), diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index fd25227712..f5c2160daa 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/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: -- **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. #### OAuth (Recommended) @@ -751,7 +751,11 @@ OpenCode supports two authentication methods: 3. Your browser opens to authorize OpenCode. Sign in and approve. :::note - OpenCode creates a Model Access Key named `opencode-oauth-` 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.