Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
54 changes: 17 additions & 37 deletions packages/opencode/src/plugin/digitalocean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()}`
Expand Down Expand Up @@ -91,15 +87,20 @@ const HTML_CALLBACK = `<!doctype html>
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
Expand Down Expand Up @@ -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(
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",
Expand Down Expand Up @@ -362,15 +342,16 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
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) {
Expand All @@ -379,12 +360,11 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
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 })),
),
Expand Down
8 changes: 6 additions & 2 deletions packages/web/src/content/docs/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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-<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.
Expand Down
Loading