Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createMemo, createSignal, onMount, Show } from "solid-js"
import open from "open"
import { useSync } from "@tui/context/sync"
import { map, pipe, sortBy } from "remeda"
import { DialogSelect } from "@tui/ui/dialog-select"
Expand Down Expand Up @@ -257,6 +258,7 @@ function AutoMethod(props: AutoMethodProps) {
}))

onMount(async () => {
open(props.authorization.url).catch(() => {})
const result = await sdk.client.provider.oauth.callback({
providerID: props.providerID,
method: props.index,
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { MinimaxAuthPlugin, MinimaxCnAuthPlugin } from "./minimax/minimax"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure"
import { DigitalOceanAuthPlugin } from "./digitalocean"
Expand Down Expand Up @@ -65,6 +66,8 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
MinimaxAuthPlugin,
MinimaxCnAuthPlugin,
DigitalOceanAuthPlugin,
]

Expand Down
291 changes: 291 additions & 0 deletions packages/opencode/src/plugin/minimax/minimax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { setTimeout as sleep } from "node:timers/promises"
import { createHash, randomBytes } from "node:crypto"


const CLIENT_ID = "d38bdbee-2b8c-4c74-9a9c-5875fabe6317"

// grant_type for device flow (RFC 8628 standard)
const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"

// OAuth scopes
const OAUTH_SCOPES = "openid profile coding_plan"

// Extra buffer before expiry to trigger proactive refresh (ms)
const REFRESH_BUFFER_MS = 5 * 60 * 1000

// Safety margin added to polling interval to avoid clock skew
const POLL_SAFETY_MARGIN_MS = 1_000

// Lane header for swimlane routing (test environments)
const LANE_HEADERS: Record<string, string> = {
...(process.env.BEDROCK_LANE ? { bedrock_lane: process.env.BEDROCK_LANE } : {}),
...(process.env.X_USER_PRE ? { "X-User-Pre": "true" } : {}),
}

function generateCodeVerifier(): string {
return randomBytes(32).toString("base64url")
}

function generateCodeChallenge(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url")
}

interface MinimaxRegionConfig {
provider: string
authBaseUrl: string
defaultResourceUrl: string
}

function createMinimaxAuthPlugin(config: MinimaxRegionConfig) {
const { provider, authBaseUrl, defaultResourceUrl } = config

async function doRefresh(refreshToken: string): Promise<{
access_token: string
refresh_token: string
expired_in: number
resource_url: string
} | null> {
const response = await fetch(`${authBaseUrl}/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", ...LANE_HEADERS },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
}),
}).catch(() => null)

if (!response || !response.ok) return null

const data = (await response.json()) as {
status: string
access_token?: string
refresh_token?: string
expired_in?: number
resource_url?: string
}

if (data.status !== "success" || !data.access_token) return null

return {
access_token: data.access_token,
refresh_token: data.refresh_token ?? refreshToken,
expired_in: data.expired_in ?? 0,
resource_url: data.resource_url ?? "",
}
}

return async function MinimaxPlugin(_input: PluginInput): Promise<Hooks> {
// Per-instance mutable auth state for runtime token refresh
let authState: {
access: string
refresh: string
expires: number
resourceUrl: string
} | null = null
let refreshing: Promise<boolean> | null = null

async function persistAuth(token: string, refresh: string, expires: number, resourceUrl: string) {
await _input.client.auth.set({
path: { id: provider },
body: {
type: "oauth",
access: token,
refresh,
expires,
enterpriseUrl: resourceUrl,
},
})
}

async function ensureFreshToken(): Promise<boolean> {
if (!authState || authState.expires <= 0) return !!authState
if (authState.expires >= Date.now() + REFRESH_BUFFER_MS) return true
if (refreshing) return refreshing

refreshing = (async () => {
try {
const refreshed = await doRefresh(authState!.refresh)
if (!refreshed) return false
authState!.access = refreshed.access_token
authState!.refresh = refreshed.refresh_token
authState!.expires = refreshed.expired_in
if (refreshed.resource_url) authState!.resourceUrl = refreshed.resource_url
await persistAuth(refreshed.access_token, refreshed.refresh_token, refreshed.expired_in, authState!.resourceUrl)
return true
} catch {
return false
} finally {
refreshing = null
}
})()
return refreshing
}

return {
"chat.headers": async (input, output) => {
if (!input.model.providerID.startsWith("minimax")) return
Object.assign(output.headers, LANE_HEADERS)

// Runtime token refresh: the auth loader only runs once at init,
// so long-running sessions need per-request expiry checks here.
if (authState && input.model.providerID === provider) {
await ensureFreshToken()
output.headers["x-api-key"] = authState.access
output.headers["Authorization"] = `Bearer ${authState.access}`
}
},
auth: {
provider,

async loader(getAuth) {
const info = await getAuth()
if (!info || info.type !== "oauth") return {}

let accessToken = info.access
// enterpriseUrl field is repurposed to store the AI API resource URL
const resourceUrl = info.enterpriseUrl ?? defaultResourceUrl

// Proactively refresh if token is expired or about to expire
let refreshToken = info.refresh
let expiresAt = info.expires
if (info.expires > 0 && info.expires < Date.now() + REFRESH_BUFFER_MS) {
const refreshed = await doRefresh(info.refresh).catch(() => null)
if (refreshed) {
accessToken = refreshed.access_token
refreshToken = refreshed.refresh_token
expiresAt = refreshed.expired_in
await persistAuth(refreshed.access_token, refreshed.refresh_token, refreshed.expired_in, refreshed.resource_url || resourceUrl)
}
}

authState = {
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
resourceUrl,
}

return {
baseURL: resourceUrl,
apiKey: accessToken,
}
},

methods: [
{
type: "oauth",
label: "Log in with MiniMax (OAuth)",

async authorize() {
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = randomBytes(16).toString("base64url")

const response = await fetch(`${authBaseUrl}/oauth2/device/code`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", ...LANE_HEADERS },
body: new URLSearchParams({
client_id: CLIENT_ID,
scope: OAUTH_SCOPES,
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
}),
})

if (!response.ok) {
throw new Error(`Failed to initiate MiniMax device authorization: ${response.status}`)
}

const data = (await response.json()) as {
verification_uri: string
user_code: string
expired_in: number
interval: number
state: string
}

if (data.state !== state) {
throw new Error("OAuth state mismatch: possible CSRF attack")
}

const pollIntervalMs = data.interval ?? 5000
const deadline = data.expired_in // Unix timestamp (ms)

return {
url: data.verification_uri,
instructions: `Enter code: ${data.user_code}`,
method: "auto" as const,

async callback() {
while (Date.now() < deadline) {
await sleep(pollIntervalMs + POLL_SAFETY_MARGIN_MS)

const tokenResponse = await fetch(`${authBaseUrl}/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", ...LANE_HEADERS },
body: new URLSearchParams({
grant_type: DEVICE_GRANT_TYPE,
client_id: CLIENT_ID,
user_code: data.user_code,
code_verifier: codeVerifier,
}),
}).catch(() => null)

if (!tokenResponse || !tokenResponse.ok) return { type: "failed" as const }

const tokenData = (await tokenResponse.json()) as {
status: string
access_token?: string
refresh_token?: string
expired_in?: number
resource_url?: string
}

if (tokenData.status === "success" && tokenData.access_token) {
return {
type: "success" as const,
access: tokenData.access_token,
refresh: tokenData.refresh_token ?? "",
expires: tokenData.expired_in ?? 0,
// Reuse enterpriseUrl field to carry the AI API resource URL
enterpriseUrl: tokenData.resource_url,
}
}

if (tokenData.status === "pending") continue

return { type: "failed" as const }
}

// Polling timed out
return { type: "failed" as const }
},
}
},
},
{
type: "api",
label: "Manually enter Token Plan key",
},
],
},
}
}
}

// Overseas (api.minimax.io)
export const MinimaxAuthPlugin = createMinimaxAuthPlugin({
provider: "minimax-coding-plan",
authBaseUrl: process.env.MINIMAX_AUTH_URL ?? "https://account.minimax.io",
defaultResourceUrl: "https://api.minimax.io/anthropic",
})

// Domestic China (api.minimaxi.com)
export const MinimaxCnAuthPlugin = createMinimaxAuthPlugin({
provider: "minimax-cn-coding-plan",
authBaseUrl: process.env.MINIMAX_CN_AUTH_URL ?? "https://account.minimaxi.com",
defaultResourceUrl: "https://api.minimaxi.com/anthropic",
})
Loading