412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
|
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"
|
|
|
|
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_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"
|
|
|
|
interface ImplicitTokenPayload {
|
|
access_token: string
|
|
expires_in: number
|
|
state: string
|
|
}
|
|
|
|
interface PendingOAuth {
|
|
state: string
|
|
resolve: (tokens: ImplicitTokenPayload) => void
|
|
reject: (error: Error) => void
|
|
}
|
|
|
|
interface ApiKeyInfo {
|
|
uuid: string
|
|
name: string
|
|
secret_key: string
|
|
}
|
|
|
|
interface RouterEntry {
|
|
name: string
|
|
uuid?: string
|
|
description?: string
|
|
}
|
|
|
|
let oauthServer: ReturnType<typeof createServer> | undefined
|
|
let pendingOAuth: PendingOAuth | undefined
|
|
|
|
function generateState(): string {
|
|
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
|
return Array.from(bytes)
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("")
|
|
}
|
|
|
|
function redirectUri(): string {
|
|
return `http://localhost:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}`
|
|
}
|
|
|
|
function buildAuthorizeUrl(state: string): string {
|
|
const params = new URLSearchParams({
|
|
response_type: "token",
|
|
client_id: DO_OAUTH_CLIENT_ID,
|
|
redirect_uri: redirectUri(),
|
|
scope: "read write",
|
|
state,
|
|
})
|
|
return `${DO_AUTHORIZE_URL}?${params.toString()}`
|
|
}
|
|
|
|
const HTML_CALLBACK = `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>OpenCode - DigitalOcean Authorization</title>
|
|
<style>
|
|
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0b1220; color: #e8eef9; }
|
|
.container { text-align: center; padding: 2rem; max-width: 32rem; }
|
|
h1 { color: #e8eef9; margin-bottom: 1rem; }
|
|
p { color: #9aa9c0; }
|
|
.error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1 id="title">Finishing sign-in...</h1>
|
|
<p id="msg">You can close this window once it says you're signed in.</p>
|
|
</div>
|
|
<script>
|
|
(async function() {
|
|
const params = new URLSearchParams((window.location.hash || "").slice(1))
|
|
const search = new URLSearchParams(window.location.search)
|
|
const error = params.get("error") || search.get("error")
|
|
const errorDescription = params.get("error_description") || search.get("error_description")
|
|
const titleEl = document.getElementById("title")
|
|
const msgEl = document.getElementById("msg")
|
|
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)}, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
if (error) {
|
|
titleEl.textContent = "Authorization Failed"
|
|
msgEl.textContent = errorDescription || error
|
|
msgEl.className = "error"
|
|
return
|
|
}
|
|
titleEl.textContent = "Authorization Successful"
|
|
msgEl.textContent = "You can close this window and return to OpenCode."
|
|
setTimeout(function () { window.close() }, 2000)
|
|
} catch (e) {
|
|
titleEl.textContent = "Authorization Failed"
|
|
msgEl.textContent = String(e && e.message ? e.message : e)
|
|
msgEl.className = "error"
|
|
}
|
|
})()
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
|
|
async function startOAuthServer(): Promise<void> {
|
|
if (oauthServer) return
|
|
oauthServer = createServer((req, res) => {
|
|
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
|
|
|
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
|
|
res.writeHead(200, { "Content-Type": "text/html" })
|
|
res.end(HTML_CALLBACK)
|
|
return
|
|
}
|
|
|
|
if (req.method === "POST" && url.pathname === OAUTH_TOKEN_PATH) {
|
|
const chunks: Buffer[] = []
|
|
req.on("data", (chunk: Buffer) => chunks.push(chunk))
|
|
req.on("end", () => {
|
|
const raw = Buffer.concat(chunks).toString("utf8")
|
|
let body: Record<string, string> = {}
|
|
try {
|
|
body = raw ? JSON.parse(raw) : {}
|
|
} catch {
|
|
body = {}
|
|
}
|
|
if (!pendingOAuth) {
|
|
res.writeHead(409, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "no_pending_oauth" }))
|
|
return
|
|
}
|
|
if (body.error) {
|
|
const message = body.error_description || body.error || "OAuth error"
|
|
pendingOAuth.reject(new Error(String(message)))
|
|
pendingOAuth = undefined
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ ok: true }))
|
|
return
|
|
}
|
|
if (!body.access_token) {
|
|
pendingOAuth.reject(new Error("Missing access_token in callback"))
|
|
pendingOAuth = undefined
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "missing_access_token" }))
|
|
return
|
|
}
|
|
if (body.state !== pendingOAuth.state) {
|
|
pendingOAuth.reject(new Error("Invalid state - potential CSRF attack"))
|
|
pendingOAuth = undefined
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "invalid_state" }))
|
|
return
|
|
}
|
|
const expires = parseInt(body.expires_in || "0", 10)
|
|
pendingOAuth.resolve({
|
|
access_token: body.access_token,
|
|
expires_in: Number.isFinite(expires) && expires > 0 ? expires : 60 * 60 * 24 * 30,
|
|
state: body.state,
|
|
})
|
|
pendingOAuth = undefined
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ ok: true }))
|
|
})
|
|
return
|
|
}
|
|
|
|
res.writeHead(404)
|
|
res.end("Not found")
|
|
})
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
oauthServer!.listen(OAUTH_PORT, () => {
|
|
log.info("digitalocean oauth server started", { port: OAUTH_PORT })
|
|
resolve()
|
|
})
|
|
oauthServer!.on("error", reject)
|
|
})
|
|
}
|
|
|
|
function stopOAuthServer() {
|
|
if (!oauthServer) return
|
|
oauthServer.close(() => log.info("digitalocean oauth server stopped"))
|
|
oauthServer = undefined
|
|
}
|
|
|
|
function waitForOAuthCallback(state: string): Promise<ImplicitTokenPayload> {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(
|
|
() => {
|
|
if (pendingOAuth) {
|
|
pendingOAuth = undefined
|
|
reject(new Error("OAuth callback timeout - authorization took too long"))
|
|
}
|
|
},
|
|
5 * 60 * 1000,
|
|
)
|
|
pendingOAuth = {
|
|
state,
|
|
resolve: (tokens) => {
|
|
clearTimeout(timeout)
|
|
resolve(tokens)
|
|
},
|
|
reject: (error) => {
|
|
clearTimeout(timeout)
|
|
reject(error)
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
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`, {
|
|
headers: {
|
|
Authorization: `Bearer ${bearer}`,
|
|
Accept: "application/json",
|
|
"User-Agent": `opencode/${InstallationVersion}`,
|
|
},
|
|
signal: AbortSignal.timeout(10_000),
|
|
}).catch(() => undefined)
|
|
if (!res) return { ok: false, status: 0 }
|
|
if (!res.ok) return { ok: false, status: res.status }
|
|
const body = (await res.json().catch(() => undefined)) as { model_routers?: RouterEntry[] } | undefined
|
|
return { ok: true, routers: body?.model_routers ?? [] }
|
|
}
|
|
|
|
function routerModel(router: RouterEntry, providerID: string): Model {
|
|
const id = `router:${router.name}`
|
|
return {
|
|
id,
|
|
providerID,
|
|
name: router.name,
|
|
family: "digitalocean-inference-routers",
|
|
api: { id, url: DO_INFERENCE_BASE, npm: "@ai-sdk/openai-compatible" },
|
|
status: "active",
|
|
headers: {},
|
|
options: {},
|
|
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
limit: { context: 128_000, output: 8_192 },
|
|
capabilities: {
|
|
temperature: true,
|
|
reasoning: false,
|
|
attachment: false,
|
|
toolcall: true,
|
|
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
interleaved: false,
|
|
},
|
|
release_date: "",
|
|
variants: {},
|
|
}
|
|
}
|
|
|
|
function parseRoutersJSON(raw: string | undefined): RouterEntry[] {
|
|
if (!raw) return []
|
|
try {
|
|
const parsed = JSON.parse(raw)
|
|
if (!Array.isArray(parsed)) return []
|
|
return parsed.flatMap((r) =>
|
|
r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [],
|
|
)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|
return {
|
|
provider: {
|
|
id: "digitalocean",
|
|
async models(provider, ctx) {
|
|
const baseModels = provider.models
|
|
if (ctx.auth?.type !== "api") return baseModels
|
|
|
|
const metadata = ctx.auth.metadata ?? {}
|
|
const oauthAccess = metadata["oauth_access"]
|
|
const oauthExpires = parseInt(metadata["oauth_expires"] || "0", 10)
|
|
const fetchedAt = parseInt(metadata["routers_fetched_at"] || "0", 10)
|
|
const cached = parseRoutersJSON(metadata["routers"])
|
|
|
|
let routers = cached
|
|
const stale = Date.now() - fetchedAt > ROUTER_REFRESH_INTERVAL_MS
|
|
const bearerValid = oauthAccess && oauthExpires > Date.now()
|
|
|
|
if (bearerValid && stale) {
|
|
const result = await listRouters(oauthAccess)
|
|
if (result.ok) {
|
|
routers = result.routers
|
|
const updated: Record<string, string> = {
|
|
...metadata,
|
|
routers: JSON.stringify(routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description }))),
|
|
routers_fetched_at: String(Date.now()),
|
|
}
|
|
await input.client.auth
|
|
.set({
|
|
path: { id: "digitalocean" },
|
|
body: { type: "api", key: ctx.auth.key, metadata: updated },
|
|
})
|
|
.catch((err) => log.warn("failed to persist refreshed routers", { error: err }))
|
|
} else if (result.status === 401 || result.status === 403) {
|
|
log.warn("digitalocean oauth bearer rejected; using cached routers", { status: result.status })
|
|
} else if (result.status !== 0) {
|
|
log.warn("digitalocean router refresh failed", { status: result.status })
|
|
}
|
|
}
|
|
|
|
const merged: Record<string, Model> = { ...baseModels }
|
|
for (const router of routers) {
|
|
const id = `router:${router.name}`
|
|
if (merged[id]) continue
|
|
merged[id] = routerModel(router, "digitalocean")
|
|
}
|
|
return merged
|
|
},
|
|
},
|
|
auth: {
|
|
provider: "digitalocean",
|
|
methods: [
|
|
{
|
|
type: "oauth",
|
|
label: "Login with DigitalOcean",
|
|
async authorize() {
|
|
await startOAuthServer()
|
|
const state = generateState()
|
|
const callbackPromise = waitForOAuthCallback(state)
|
|
return {
|
|
url: buildAuthorizeUrl(state),
|
|
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.",
|
|
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) {
|
|
log.warn("digitalocean initial router fetch failed", { status: routerResult.status })
|
|
}
|
|
return {
|
|
type: "success" as const,
|
|
provider: "digitalocean",
|
|
key: apiKeyInfo.secret_key,
|
|
metadata: {
|
|
mak_uuid: apiKeyInfo.uuid,
|
|
mak_name: apiKeyInfo.name,
|
|
oauth_access: tokens.access_token,
|
|
oauth_expires: String(Date.now() + tokens.expires_in * 1000),
|
|
routers: JSON.stringify(
|
|
routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })),
|
|
),
|
|
routers_fetched_at: String(Date.now()),
|
|
},
|
|
}
|
|
} catch (err) {
|
|
log.error("digitalocean oauth callback failed", { error: err })
|
|
return { type: "failed" as const }
|
|
} finally {
|
|
stopOAuthServer()
|
|
}
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
type: "api",
|
|
label: "Paste Model Access Key",
|
|
},
|
|
],
|
|
},
|
|
}
|
|
}
|