Explorar o código

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

Musa hai 3 meses
pai
achega
159964b172

+ 4 - 1
packages/opencode/src/cli/cmd/providers.ts

@@ -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")
     }

+ 407 - 0
packages/opencode/src/plugin/digitalocean.ts

@@ -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 - 0
packages/opencode/src/plugin/index.ts

@@ -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 - 0
packages/opencode/src/provider/auth.ts

@@ -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 } : {}),
         })
       }
 

+ 144 - 0
packages/opencode/test/provider/digitalocean.test.ts

@@ -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)
+    },
+  })
+})

+ 24 - 0
packages/opencode/test/tool/fixtures/models-api.json

@@ -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"],

+ 4 - 4
packages/plugin/src/index.ts

@@ -8,10 +8,9 @@ import type {
   UserMessage,
   Message,
   Part,
-  Auth,
   Config as SDKConfig,
 } from "@opencode-ai/sdk"
-import type { Provider as ProviderV2, Model as ModelV2 } from "@opencode-ai/sdk/v2"
+import type { Provider as ProviderV2, Model as ModelV2, Auth } from "@opencode-ai/sdk/v2"
 
 import type { BunShell } from "./shell.js"
 import { type ToolDefinition } from "./tool.js"
@@ -153,6 +152,7 @@ export type AuthHook = {
               type: "success"
               key: string
               provider?: string
+              metadata?: Record<string, string>
             }
           | {
               type: "failed"
@@ -177,7 +177,7 @@ export type AuthOAuthResult = { url: string; instructions: string } & (
                 accountId?: string
                 enterpriseUrl?: string
               }
-            | { key: string }
+            | { key: string; metadata?: Record<string, string> }
           ))
         | {
             type: "failed"
@@ -198,7 +198,7 @@ export type AuthOAuthResult = { url: string; instructions: string } & (
                 accountId?: string
                 enterpriseUrl?: string
               }
-            | { key: string }
+            | { key: string; metadata?: Record<string, string> }
           ))
         | {
             type: "failed"

+ 3 - 0
packages/sdk/js/src/gen/types.gen.ts

@@ -1666,6 +1666,9 @@ export type OAuth = {
 export type ApiAuth = {
   type: "api"
   key: string
+  metadata?: {
+    [key: string]: string
+  }
 }
 
 export type WellKnownAuth = {

+ 6 - 0
packages/ui/src/assets/icons/provider/digitalocean.svg

@@ -0,0 +1,6 @@
+<svg width="24" height="24" viewBox="-14 -14 100.8 100.8" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
+  <polygon fill-rule="evenodd" points="36.4 58.7 22.4 58.7 22.4 44.6 22.4 44.6 36.4 44.6 36.4 44.6 36.4 58.7"/>
+  <polygon fill-rule="evenodd" points="22.4 69.5 11.6 69.5 11.6 69.5 11.6 58.7 22.4 58.7 22.4 69.5"/>
+  <polygon fill-rule="evenodd" points="11.6 58.7 2.5 58.7 2.5 58.7 2.5 49.6 2.5 49.6 11.5 49.6 11.6 49.6 11.6 58.7"/>
+  <path d="M36.4,0C16.3,0,0,16.3,0,36.4h14.1c0-12.3,10-22.3,22.3-22.3s22.3,10,22.3,22.3-10,22.3-22.3,22.3h0v14.1h0c20.1,0,36.4-16.3,36.4-36.4S56.5,0,36.4,0Z"/>
+</svg>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/ui/src/components/provider-icons/sprite.svg


+ 1 - 0
packages/ui/src/components/provider-icons/types.ts

@@ -78,6 +78,7 @@ export const iconNames = [
   "fireworks-ai",
   "fastrouter",
   "evroc",
+  "digitalocean",
   "deepseek",
   "deepinfra",
   "cortecs",

+ 82 - 0
packages/web/src/content/docs/providers.mdx

@@ -721,6 +721,88 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
 
 ---
 
+### DigitalOcean
+
+DigitalOcean's [Inference Engine](https://docs.digitalocean.com/products/inference/) provides access to open models like GPT-OSS, Llama, Qwen, and DeepSeek, plus custom [Inference Routers](https://docs.digitalocean.com/products/genai-platform/concepts/inference-routers/) that route each request to the cheapest, fastest, or best-fit model for a task.
+
+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.
+- **Model Access Key** — Paste an existing key from the DigitalOcean console.
+
+#### OAuth (Recommended)
+
+1. Run the `/connect` command and search for **DigitalOcean**.
+
+   ```txt
+   /connect
+   ```
+
+2. Select **Login with DigitalOcean**.
+
+   ```txt
+   ┌ Select auth method
+   │
+   │ Login with DigitalOcean
+   │ Paste Model Access Key
+   └
+   ```
+
+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.
+   :::
+
+4. Run the `/models` command. Your Inference Routers appear as the format `router:` in the model selection.
+
+   ```txt
+   /models
+   ```
+
+5. To pick up newly created Inference Routers, re-run `/connect` and select **DigitalOcean** again.
+
+#### Using a Model Access Key
+
+If you'd rather paste a key directly:
+
+1. Head over to the **Manage** page in the Inference section of the [DigitalOcean console](https://cloud.digitalocean.com/) and create a new key.
+
+2. Run the `/connect` command and select **DigitalOcean**, then **Paste Model Access Key**.
+
+   ```txt
+   ┌ Enter your DigitalOcean Model Access Key
+   │
+   │
+   └ enter
+   ```
+
+   :::note
+   Inference Routers are not auto-discovered with this method. To surface them in the model picker, sign in via OAuth instead.
+   :::
+
+3. Run the `/models` command to select a model.
+
+   ```txt
+   /models
+   ```
+
+#### Environment Variable
+
+Alternatively, set your Model Access Key as an environment variable.
+
+```bash frame="none"
+export DIGITALOCEAN_ACCESS_TOKEN=your-model-access-key
+```
+
+#### Inference Routers
+
+Inference Routers let you define a routing policy across multiple models — picking the cheapest, fastest, or most appropriate model per request based on the task. After OAuth, OpenCode surfaces each router as `router:<router-name>` in the model picker.
+
+Selecting a router model is a drop-in replacement for any other model — OpenCode forwards your request and DigitalOcean picks the underlying model based on your router's policy. Learn more about [Inference Routers](https://docs.digitalocean.com/products/inference/how-to/use-inference-router/)
+
+---
+
 ### FrogBot
 
 1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key.

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio