feat(plugin): add DigitalOcean OAuth + Inference Routers (#26095)

This commit is contained in:
Musa
2026-05-12 16:22:40 -05:00
committed by GitHub
parent d9f9f1553b
commit 159964b172
12 changed files with 692 additions and 5 deletions
+4 -1
View File
@@ -124,6 +124,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
yield* put(saveProvider, {
type: "api",
key: result.key,
...(result.metadata ? { metadata: result.metadata } : {}),
})
}
yield* spinner.stop("Login successful")
@@ -156,6 +157,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
yield* put(saveProvider, {
type: "api",
key: result.key,
...(result.metadata ? { metadata: result.metadata } : {}),
})
}
yield* Prompt.log.success("Login successful")
@@ -191,10 +193,11 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
const merged = { ...(metadata.metadata ?? {}), ...(result.metadata ?? {}) }
yield* put(saveProvider, {
type: "api",
key: result.key ?? apiKey,
...metadata,
...(Object.keys(merged).length ? { metadata: merged } : {}),
})
yield* Prompt.log.success("Login successful")
}
@@ -0,0 +1,407 @@
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",
},
],
},
}
}
+2
View File
@@ -19,6 +19,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure"
import { DigitalOceanAuthPlugin } from "./digitalocean"
import { Effect, Layer, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
@@ -64,6 +65,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
DigitalOceanAuthPlugin,
]
function isServerPlugin(value: unknown): value is PluginInstance {
+1
View File
@@ -197,6 +197,7 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
yield* auth.set(input.providerID, {
type: "api",
key: result.key,
...(result.metadata ? { metadata: result.metadata } : {}),
})
}
@@ -0,0 +1,144 @@
import { test, expect, afterEach } from "bun:test"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Provider } from "../../src/provider/provider"
import { ProviderID } from "../../src/provider/schema"
import { Env } from "../../src/env"
import { Effect } from "effect"
import { AppRuntime } from "../../src/effect/app-runtime"
import { makeRuntime } from "../../src/effect/run-service"
const envRuntime = makeRuntime(Env.Service, Env.defaultLayer)
const set = (k: string, v: string) => envRuntime.runSync((svc) => svc.set(k, v))
async function list() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.list()
}),
)
}
const DIGITALOCEAN = ProviderID.make("digitalocean")
const originalAuthContent = process.env.OPENCODE_AUTH_CONTENT
afterEach(() => {
if (originalAuthContent === undefined) delete process.env.OPENCODE_AUTH_CONTENT
else process.env.OPENCODE_AUTH_CONTENT = originalAuthContent
})
function injectAuth(metadata: Record<string, string> | undefined) {
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({
digitalocean: {
type: "api",
key: "sk_do_test",
...(metadata ? { metadata } : {}),
},
})
}
test("digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("DIGITALOCEAN_ACCESS_TOKEN", "test-token")
const providers = await list()
expect(providers[DIGITALOCEAN]).toBeDefined()
expect(providers[DIGITALOCEAN].source).toBe("env")
const baseModel = Object.values(providers[DIGITALOCEAN].models)[0]
expect(baseModel.api.url).toBe("https://inference.do-ai.run/v1")
expect(baseModel.api.npm).toBe("@ai-sdk/openai-compatible")
const routerEntries = Object.keys(providers[DIGITALOCEAN].models).filter((id) => id.startsWith("router:"))
expect(routerEntries.length).toBe(0)
},
})
})
test("digitalocean provider.models surfaces cached routers from auth metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
)
},
})
injectAuth({
routers: JSON.stringify([
{ name: "my-router", uuid: "11f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
{ name: "other-router", uuid: "22f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
]),
routers_fetched_at: String(Date.now()),
oauth_access: "doo_v1_test",
oauth_expires: String(Date.now() + 60 * 60 * 1000),
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const models = providers[DIGITALOCEAN].models
expect(models["router:my-router"]).toBeDefined()
expect(models["router:my-router"].api.id).toBe("router:my-router")
expect(models["router:my-router"].api.url).toBe("https://inference.do-ai.run/v1")
expect(models["router:my-router"].api.npm).toBe("@ai-sdk/openai-compatible")
expect(models["router:other-router"]).toBeDefined()
},
})
})
test("digitalocean provider.models skips refresh when oauth bearer is expired", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
)
},
})
injectAuth({
routers: JSON.stringify([{ name: "stale-router", uuid: "stale" }]),
routers_fetched_at: "0",
oauth_access: "doo_v1_expired",
oauth_expires: "1",
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const providers = await list()
const models = providers[DIGITALOCEAN].models
expect(models["router:stale-router"]).toBeDefined()
},
})
})
test("digitalocean provider.models passes through base models when no auth metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
set("DIGITALOCEAN_ACCESS_TOKEN", "test-token")
const providers = await list()
const models = providers[DIGITALOCEAN].models
expect(Object.keys(models).length).toBeGreaterThan(0)
expect(Object.keys(models).filter((id) => id.startsWith("router:")).length).toBe(0)
},
})
})
@@ -1,4 +1,28 @@
{
"digitalocean": {
"id": "digitalocean",
"env": ["DIGITALOCEAN_ACCESS_TOKEN"],
"npm": "@ai-sdk/openai-compatible",
"api": "https://inference.do-ai.run/v1",
"name": "DigitalOcean",
"doc": "https://docs.digitalocean.com/products/genai-platform/",
"models": {
"openai-gpt-oss-120b": {
"id": "openai-gpt-oss-120b",
"name": "GPT OSS 120B",
"attachment": false,
"reasoning": false,
"tool_call": true,
"temperature": true,
"release_date": "2025-08-05",
"last_updated": "2025-08-05",
"modalities": { "input": ["text"], "output": ["text"] },
"open_weights": false,
"cost": { "input": 0.35, "output": 0.75 },
"limit": { "context": 128000, "output": 16384 }
}
}
},
"ollama-cloud": {
"id": "ollama-cloud",
"env": ["OLLAMA_API_KEY"],