mirror of
https://github.com/sst/opencode.git
synced 2025-08-22 14:04:07 +00:00
allow plugins to create custom auth providers
This commit is contained in:
parent
c93d50e8c7
commit
a433766a31
11 changed files with 372 additions and 438 deletions
|
@ -1,84 +0,0 @@
|
||||||
import { generatePKCE } from "@openauthjs/openauth/pkce"
|
|
||||||
import { Auth } from "./index"
|
|
||||||
|
|
||||||
export namespace AuthAnthropic {
|
|
||||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
||||||
|
|
||||||
export async function authorize(mode: "max" | "console") {
|
|
||||||
const pkce = await generatePKCE()
|
|
||||||
|
|
||||||
const url = new URL(
|
|
||||||
`https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`,
|
|
||||||
import.meta.url,
|
|
||||||
)
|
|
||||||
url.searchParams.set("code", "true")
|
|
||||||
url.searchParams.set("client_id", CLIENT_ID)
|
|
||||||
url.searchParams.set("response_type", "code")
|
|
||||||
url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
|
|
||||||
url.searchParams.set("scope", "org:create_api_key user:profile user:inference")
|
|
||||||
url.searchParams.set("code_challenge", pkce.challenge)
|
|
||||||
url.searchParams.set("code_challenge_method", "S256")
|
|
||||||
url.searchParams.set("state", pkce.verifier)
|
|
||||||
return {
|
|
||||||
url: url.toString(),
|
|
||||||
verifier: pkce.verifier,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exchange(code: string, verifier: string) {
|
|
||||||
const splits = code.split("#")
|
|
||||||
const result = await fetch("https://console.anthropic.com/v1/oauth/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
code: splits[0],
|
|
||||||
state: splits[1],
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
redirect_uri: "https://console.anthropic.com/oauth/code/callback",
|
|
||||||
code_verifier: verifier,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!result.ok) throw new ExchangeFailed()
|
|
||||||
const json = await result.json()
|
|
||||||
return {
|
|
||||||
refresh: json.refresh_token as string,
|
|
||||||
access: json.access_token as string,
|
|
||||||
expires: Date.now() + json.expires_in * 1000,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function access() {
|
|
||||||
const info = await Auth.get("anthropic")
|
|
||||||
if (!info || info.type !== "oauth") return
|
|
||||||
if (info.access && info.expires > Date.now()) return info.access
|
|
||||||
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: info.refresh,
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!response.ok) return
|
|
||||||
const json = await response.json()
|
|
||||||
await Auth.set("anthropic", {
|
|
||||||
type: "oauth",
|
|
||||||
refresh: json.refresh_token as string,
|
|
||||||
access: json.access_token as string,
|
|
||||||
expires: Date.now() + json.expires_in * 1000,
|
|
||||||
})
|
|
||||||
return json.access_token as string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ExchangeFailed extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Exchange failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { Global } from "../global"
|
|
||||||
import { lazy } from "../util/lazy"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
export const AuthCopilot = lazy(async () => {
|
|
||||||
const file = Bun.file(path.join(Global.Path.state, "plugin", "copilot.ts"))
|
|
||||||
const exists = await file.exists()
|
|
||||||
const response = fetch("https://raw.githubusercontent.com/sst/opencode-github-copilot/refs/heads/main/auth.ts")
|
|
||||||
.then((x) => Bun.write(file, x))
|
|
||||||
.catch(() => {})
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
const worked = await response
|
|
||||||
if (!worked) return
|
|
||||||
}
|
|
||||||
const result = await import(file.name!).catch(() => {})
|
|
||||||
if (!result) return
|
|
||||||
return result.AuthCopilot
|
|
||||||
})
|
|
|
@ -4,25 +4,31 @@ import fs from "fs/promises"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
export const Oauth = z.object({
|
export const Oauth = z
|
||||||
type: z.literal("oauth"),
|
.object({
|
||||||
refresh: z.string(),
|
type: z.literal("oauth"),
|
||||||
access: z.string(),
|
refresh: z.string(),
|
||||||
expires: z.number(),
|
access: z.string(),
|
||||||
})
|
expires: z.number(),
|
||||||
|
})
|
||||||
|
.openapi({ ref: "OAuth" })
|
||||||
|
|
||||||
export const Api = z.object({
|
export const Api = z
|
||||||
type: z.literal("api"),
|
.object({
|
||||||
key: z.string(),
|
type: z.literal("api"),
|
||||||
})
|
key: z.string(),
|
||||||
|
})
|
||||||
|
.openapi({ ref: "ApiAuth" })
|
||||||
|
|
||||||
export const WellKnown = z.object({
|
export const WellKnown = z
|
||||||
type: z.literal("wellknown"),
|
.object({
|
||||||
key: z.string(),
|
type: z.literal("wellknown"),
|
||||||
token: z.string(),
|
key: z.string(),
|
||||||
})
|
token: z.string(),
|
||||||
|
})
|
||||||
|
.openapi({ ref: "WellKnownAuth" })
|
||||||
|
|
||||||
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown])
|
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).openapi({ ref: "Auth" })
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
const filepath = path.join(Global.Path.data, "auth.json")
|
const filepath = path.join(Global.Path.data, "auth.json")
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { AuthAnthropic } from "../../auth/anthropic"
|
|
||||||
import { AuthCopilot } from "../../auth/copilot"
|
|
||||||
import { Auth } from "../../auth"
|
import { Auth } from "../../auth"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
|
@ -10,6 +8,8 @@ import { map, pipe, sortBy, values } from "remeda"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { Global } from "../../global"
|
import { Global } from "../../global"
|
||||||
|
import { Plugin } from "../../plugin"
|
||||||
|
import { App } from "../../app/app"
|
||||||
|
|
||||||
export const AuthCommand = cmd({
|
export const AuthCommand = cmd({
|
||||||
command: "auth",
|
command: "auth",
|
||||||
|
@ -75,242 +75,179 @@ export const AuthLoginCommand = cmd({
|
||||||
type: "string",
|
type: "string",
|
||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
await App.provide({ cwd: process.cwd() }, async () => {
|
||||||
prompts.intro("Add credential")
|
UI.empty()
|
||||||
if (args.url) {
|
prompts.intro("Add credential")
|
||||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
|
if (args.url) {
|
||||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
|
||||||
const proc = Bun.spawn({
|
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||||
cmd: wellknown.auth.command,
|
const proc = Bun.spawn({
|
||||||
stdout: "pipe",
|
cmd: wellknown.auth.command,
|
||||||
})
|
stdout: "pipe",
|
||||||
const exit = await proc.exited
|
})
|
||||||
if (exit !== 0) {
|
const exit = await proc.exited
|
||||||
prompts.log.error("Failed")
|
if (exit !== 0) {
|
||||||
|
prompts.log.error("Failed")
|
||||||
|
prompts.outro("Done")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const token = await new Response(proc.stdout).text()
|
||||||
|
await Auth.set(args.url, {
|
||||||
|
type: "wellknown",
|
||||||
|
key: wellknown.auth.env,
|
||||||
|
token: token.trim(),
|
||||||
|
})
|
||||||
|
prompts.log.success("Logged into " + args.url)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const token = await new Response(proc.stdout).text()
|
await ModelsDev.refresh().catch(() => {})
|
||||||
await Auth.set(args.url, {
|
const providers = await ModelsDev.get()
|
||||||
type: "wellknown",
|
const priority: Record<string, number> = {
|
||||||
key: wellknown.auth.env,
|
anthropic: 0,
|
||||||
token: token.trim(),
|
"github-copilot": 1,
|
||||||
})
|
openai: 2,
|
||||||
prompts.log.success("Logged into " + args.url)
|
google: 3,
|
||||||
prompts.outro("Done")
|
openrouter: 4,
|
||||||
return
|
vercel: 5,
|
||||||
}
|
}
|
||||||
await ModelsDev.refresh().catch(() => {})
|
let provider = await prompts.autocomplete({
|
||||||
const providers = await ModelsDev.get()
|
message: "Select provider",
|
||||||
const priority: Record<string, number> = {
|
maxItems: 8,
|
||||||
anthropic: 0,
|
|
||||||
"github-copilot": 1,
|
|
||||||
openai: 2,
|
|
||||||
google: 3,
|
|
||||||
openrouter: 4,
|
|
||||||
vercel: 5,
|
|
||||||
}
|
|
||||||
let provider = await prompts.autocomplete({
|
|
||||||
message: "Select provider",
|
|
||||||
maxItems: 8,
|
|
||||||
options: [
|
|
||||||
...pipe(
|
|
||||||
providers,
|
|
||||||
values(),
|
|
||||||
sortBy(
|
|
||||||
(x) => priority[x.id] ?? 99,
|
|
||||||
(x) => x.name ?? x.id,
|
|
||||||
),
|
|
||||||
map((x) => ({
|
|
||||||
label: x.name,
|
|
||||||
value: x.id,
|
|
||||||
hint: priority[x.id] === 0 ? "recommended" : undefined,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
{
|
|
||||||
value: "other",
|
|
||||||
label: "Other",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
|
||||||
|
|
||||||
if (provider === "other") {
|
|
||||||
provider = await prompts.text({
|
|
||||||
message: "Enter provider id",
|
|
||||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
|
||||||
provider = provider.replace(/^@ai-sdk\//, "")
|
|
||||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
|
||||||
prompts.log.warn(
|
|
||||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "amazon-bedrock") {
|
|
||||||
prompts.log.info(
|
|
||||||
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
|
|
||||||
)
|
|
||||||
prompts.outro("Done")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "anthropic") {
|
|
||||||
const method = await prompts.select({
|
|
||||||
message: "Login method",
|
|
||||||
options: [
|
options: [
|
||||||
|
...pipe(
|
||||||
|
providers,
|
||||||
|
values(),
|
||||||
|
sortBy(
|
||||||
|
(x) => priority[x.id] ?? 99,
|
||||||
|
(x) => x.name ?? x.id,
|
||||||
|
),
|
||||||
|
map((x) => ({
|
||||||
|
label: x.name,
|
||||||
|
value: x.id,
|
||||||
|
hint: priority[x.id] === 0 ? "recommended" : undefined,
|
||||||
|
})),
|
||||||
|
),
|
||||||
{
|
{
|
||||||
label: "Claude Pro/Max",
|
value: "other",
|
||||||
value: "max",
|
label: "Other",
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Create API Key",
|
|
||||||
value: "console",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Manually enter API Key",
|
|
||||||
value: "api",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
|
||||||
|
|
||||||
if (method === "max") {
|
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||||
// some weird bug where program exits without this
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
const { url, verifier } = await AuthAnthropic.authorize("max")
|
|
||||||
prompts.note("Trying to open browser...")
|
|
||||||
try {
|
|
||||||
await open(url)
|
|
||||||
} catch (e) {
|
|
||||||
prompts.log.error(
|
|
||||||
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
prompts.log.info(url)
|
|
||||||
|
|
||||||
const code = await prompts.text({
|
const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
|
||||||
message: "Paste the authorization code here: ",
|
if (plugin && plugin.auth) {
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
let index = 0
|
||||||
})
|
if (plugin.auth.methods.length > 1) {
|
||||||
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
const method = await prompts.select({
|
||||||
|
message: "Login method",
|
||||||
try {
|
options: [
|
||||||
const credentials = await AuthAnthropic.exchange(code, verifier)
|
...plugin.auth.methods.map((x, index) => ({
|
||||||
await Auth.set("anthropic", {
|
label: x.label,
|
||||||
type: "oauth",
|
value: index.toString(),
|
||||||
refresh: credentials.refresh,
|
})),
|
||||||
access: credentials.access,
|
],
|
||||||
expires: credentials.expires,
|
|
||||||
})
|
})
|
||||||
prompts.log.success("Login successful")
|
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
||||||
} catch {
|
index = parseInt(method)
|
||||||
prompts.log.error("Invalid code")
|
|
||||||
}
|
}
|
||||||
prompts.outro("Done")
|
const method = plugin.auth.methods[index]
|
||||||
return
|
if (method.type === "oauth") {
|
||||||
}
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
const authorize = await method.authorize()
|
||||||
|
|
||||||
if (method === "console") {
|
if (authorize.url) {
|
||||||
// some weird bug where program exits without this
|
try {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
await open(authorize.url)
|
||||||
const { url, verifier } = await AuthAnthropic.authorize("console")
|
} catch (e) {}
|
||||||
prompts.note("Trying to open browser...")
|
prompts.log.info("Go to: " + authorize.url)
|
||||||
try {
|
|
||||||
await open(url)
|
|
||||||
} catch (e) {
|
|
||||||
prompts.log.error(
|
|
||||||
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
prompts.log.info(url)
|
|
||||||
|
|
||||||
const code = await prompts.text({
|
|
||||||
message: "Paste the authorization code here: ",
|
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
|
||||||
})
|
|
||||||
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const credentials = await AuthAnthropic.exchange(code, verifier)
|
|
||||||
const accessToken = credentials.access
|
|
||||||
const response = await fetch("https://api.anthropic.com/api/oauth/claude_cli/create_api_key", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json, text/plain, */*",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to create API key")
|
|
||||||
}
|
}
|
||||||
const json = await response.json()
|
|
||||||
await Auth.set("anthropic", {
|
|
||||||
type: "api",
|
|
||||||
key: json.raw_key,
|
|
||||||
})
|
|
||||||
|
|
||||||
prompts.log.success("Login successful - API key created and saved")
|
if (authorize.method === "auto") {
|
||||||
} catch (error) {
|
if (authorize.instructions) {
|
||||||
prompts.log.error("Invalid code or failed to create API key")
|
prompts.log.info(authorize.instructions)
|
||||||
|
}
|
||||||
|
const spinner = prompts.spinner()
|
||||||
|
spinner.start("Waiting for authorization...")
|
||||||
|
const result = await authorize.callback()
|
||||||
|
if (result.type === "failed") {
|
||||||
|
spinner.stop("Failed to authorize", 1)
|
||||||
|
}
|
||||||
|
if (result.type === "success") {
|
||||||
|
await Auth.set(provider, {
|
||||||
|
type: "oauth",
|
||||||
|
refresh: result.refresh,
|
||||||
|
access: result.access,
|
||||||
|
expires: result.expires,
|
||||||
|
})
|
||||||
|
spinner.stop("Login successful")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorize.method === "code") {
|
||||||
|
const code = await prompts.text({
|
||||||
|
message: "Paste the authorization code here: ",
|
||||||
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
||||||
|
const result = await authorize.callback(code)
|
||||||
|
if (result.type === "failed") {
|
||||||
|
prompts.log.error("Failed to authorize")
|
||||||
|
}
|
||||||
|
if (result.type === "success") {
|
||||||
|
await Auth.set(provider, {
|
||||||
|
type: "oauth",
|
||||||
|
refresh: result.refresh,
|
||||||
|
access: result.access,
|
||||||
|
expires: result.expires,
|
||||||
|
})
|
||||||
|
prompts.log.success("Login successful")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prompts.outro("Done")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "other") {
|
||||||
|
provider = await prompts.text({
|
||||||
|
message: "Enter provider id",
|
||||||
|
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||||
|
provider = provider.replace(/^@ai-sdk\//, "")
|
||||||
|
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||||
|
prompts.log.warn(
|
||||||
|
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "amazon-bedrock") {
|
||||||
|
prompts.log.info(
|
||||||
|
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
|
||||||
|
)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const copilot = await AuthCopilot()
|
if (provider === "vercel") {
|
||||||
if (provider === "github-copilot" && copilot) {
|
prompts.log.info("You can create an api key in the dashboard")
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
const deviceInfo = await copilot.authorize()
|
|
||||||
|
|
||||||
prompts.note(`Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`)
|
|
||||||
|
|
||||||
const spinner = prompts.spinner()
|
|
||||||
spinner.start("Waiting for authorization...")
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, deviceInfo.interval * 1000))
|
|
||||||
const response = await copilot.poll(deviceInfo.device)
|
|
||||||
if (response.status === "pending") continue
|
|
||||||
if (response.status === "success") {
|
|
||||||
await Auth.set("github-copilot", {
|
|
||||||
type: "oauth",
|
|
||||||
refresh: response.refresh,
|
|
||||||
access: response.access,
|
|
||||||
expires: response.expires,
|
|
||||||
})
|
|
||||||
spinner.stop("Login successful")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (response.status === "failed") {
|
|
||||||
spinner.stop("Failed to authorize", 1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = await prompts.password({
|
||||||
|
message: "Enter your API key",
|
||||||
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||||
|
await Auth.set(provider, {
|
||||||
|
type: "api",
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "vercel") {
|
|
||||||
prompts.log.info("You can create an api key in the dashboard")
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = await prompts.password({
|
|
||||||
message: "Enter your API key",
|
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
|
||||||
await Auth.set(provider, {
|
|
||||||
type: "api",
|
|
||||||
key,
|
|
||||||
})
|
|
||||||
|
|
||||||
prompts.outro("Done")
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ export namespace Flag {
|
||||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||||
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
|
||||||
|
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
|
||||||
|
|
||||||
function truthy(key: string) {
|
function truthy(key: string) {
|
||||||
const value = process.env[key]?.toLowerCase()
|
const value = process.env[key]?.toLowerCase()
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { Log } from "../util/log"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import { Server } from "../server/server"
|
import { Server } from "../server/server"
|
||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
|
import { Flag } from "../flag/flag"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
@ -17,7 +18,17 @@ export namespace Plugin {
|
||||||
})
|
})
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
const hooks = []
|
const hooks = []
|
||||||
for (let plugin of config.plugin ?? []) {
|
const input = {
|
||||||
|
client,
|
||||||
|
app,
|
||||||
|
$: Bun.$,
|
||||||
|
}
|
||||||
|
const plugins = [...(config.plugin ?? [])]
|
||||||
|
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||||
|
plugins.push("opencode-copilot-auth")
|
||||||
|
plugins.push("opencode-anthropic-auth")
|
||||||
|
}
|
||||||
|
for (let plugin of plugins) {
|
||||||
log.info("loading plugin", { path: plugin })
|
log.info("loading plugin", { path: plugin })
|
||||||
if (!plugin.startsWith("file://")) {
|
if (!plugin.startsWith("file://")) {
|
||||||
const [pkg, version] = plugin.split("@")
|
const [pkg, version] = plugin.split("@")
|
||||||
|
@ -25,22 +36,19 @@ export namespace Plugin {
|
||||||
}
|
}
|
||||||
const mod = await import(plugin)
|
const mod = await import(plugin)
|
||||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||||
const init = await fn({
|
const init = await fn(input)
|
||||||
client,
|
|
||||||
app,
|
|
||||||
$: Bun.$,
|
|
||||||
})
|
|
||||||
hooks.push(init)
|
hooks.push(init)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hooks,
|
hooks,
|
||||||
|
input,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function trigger<
|
export async function trigger<
|
||||||
Name extends keyof Required<Hooks>,
|
Name extends Exclude<keyof Required<Hooks>, "auth" | "event">,
|
||||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||||
>(name: Name, input: Input, output: Output): Promise<Output> {
|
>(name: Name, input: Input, output: Output): Promise<Output> {
|
||||||
|
@ -56,6 +64,10 @@ export namespace Plugin {
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function list() {
|
||||||
|
return state().then((x) => x.hooks)
|
||||||
|
}
|
||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
Bus.subscribeAll(async (input) => {
|
Bus.subscribeAll(async (input) => {
|
||||||
const hooks = await state().then((x) => x.hooks)
|
const hooks = await state().then((x) => x.hooks)
|
||||||
|
|
|
@ -5,8 +5,7 @@ import { mergeDeep, sortBy } from "remeda"
|
||||||
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
|
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
import { AuthAnthropic } from "../auth/anthropic"
|
import { Plugin } from "../plugin"
|
||||||
import { AuthCopilot } from "../auth/copilot"
|
|
||||||
import { ModelsDev } from "./models"
|
import { ModelsDev } from "./models"
|
||||||
import { NamedError } from "../util/error"
|
import { NamedError } from "../util/error"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
|
@ -26,103 +25,13 @@ export namespace Provider {
|
||||||
type Source = "env" | "config" | "custom" | "api"
|
type Source = "env" | "config" | "custom" | "api"
|
||||||
|
|
||||||
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
||||||
async anthropic(provider) {
|
async anthropic() {
|
||||||
const access = await AuthAnthropic.access()
|
|
||||||
if (!access)
|
|
||||||
return {
|
|
||||||
autoload: false,
|
|
||||||
options: {
|
|
||||||
headers: {
|
|
||||||
"anthropic-beta":
|
|
||||||
"claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for (const model of Object.values(provider.models)) {
|
|
||||||
model.cost = {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
autoload: true,
|
autoload: false,
|
||||||
options: {
|
options: {
|
||||||
apiKey: "",
|
headers: {
|
||||||
async fetch(input: any, init: any) {
|
"anthropic-beta":
|
||||||
const access = await AuthAnthropic.access()
|
"claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
||||||
const headers = {
|
|
||||||
...init.headers,
|
|
||||||
authorization: `Bearer ${access}`,
|
|
||||||
"anthropic-beta":
|
|
||||||
"oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
|
||||||
}
|
|
||||||
delete headers["x-api-key"]
|
|
||||||
return fetch(input, {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"github-copilot": async (provider) => {
|
|
||||||
const copilot = await AuthCopilot()
|
|
||||||
if (!copilot) return { autoload: false }
|
|
||||||
let info = await Auth.get("github-copilot")
|
|
||||||
if (!info || info.type !== "oauth") return { autoload: false }
|
|
||||||
|
|
||||||
if (provider && provider.models) {
|
|
||||||
for (const model of Object.values(provider.models)) {
|
|
||||||
model.cost = {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
autoload: true,
|
|
||||||
options: {
|
|
||||||
apiKey: "",
|
|
||||||
async fetch(input: any, init: any) {
|
|
||||||
const info = await Auth.get("github-copilot")
|
|
||||||
if (!info || info.type !== "oauth") return
|
|
||||||
if (!info.access || info.expires < Date.now()) {
|
|
||||||
const tokens = await copilot.access(info.refresh)
|
|
||||||
if (!tokens) throw new Error("GitHub Copilot authentication expired")
|
|
||||||
await Auth.set("github-copilot", {
|
|
||||||
type: "oauth",
|
|
||||||
...tokens,
|
|
||||||
})
|
|
||||||
info.access = tokens.access
|
|
||||||
}
|
|
||||||
let isAgentCall = false
|
|
||||||
let isVisionRequest = false
|
|
||||||
try {
|
|
||||||
const body = typeof init.body === "string" ? JSON.parse(init.body) : init.body
|
|
||||||
if (body?.messages) {
|
|
||||||
isAgentCall = body.messages.some((msg: any) => msg.role && ["tool", "assistant"].includes(msg.role))
|
|
||||||
isVisionRequest = body.messages.some(
|
|
||||||
(msg: any) =>
|
|
||||||
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
...init.headers,
|
|
||||||
...copilot.HEADERS,
|
|
||||||
Authorization: `Bearer ${info.access}`,
|
|
||||||
"Openai-Intent": "conversation-edits",
|
|
||||||
"X-Initiator": isAgentCall ? "agent" : "user",
|
|
||||||
}
|
|
||||||
if (isVisionRequest) {
|
|
||||||
headers["Copilot-Vision-Request"] = "true"
|
|
||||||
}
|
|
||||||
delete headers["x-api-key"]
|
|
||||||
return fetch(input, {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -350,6 +259,17 @@ export namespace Provider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const plugin of await Plugin.list()) {
|
||||||
|
if (!plugin.auth) continue
|
||||||
|
const providerID = plugin.auth.provider
|
||||||
|
if (disabled.has(providerID)) continue
|
||||||
|
const auth = await Auth.get(providerID)
|
||||||
|
if (!auth) continue
|
||||||
|
if (!plugin.auth.loader) continue
|
||||||
|
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
|
||||||
|
mergeProvider(plugin.auth.provider, options ?? {}, "custom")
|
||||||
|
}
|
||||||
|
|
||||||
// load config
|
// load config
|
||||||
for (const [providerID, provider] of configProviders) {
|
for (const [providerID, provider] of configProviders) {
|
||||||
mergeProvider(providerID, provider.options ?? {}, "config")
|
mergeProvider(providerID, provider.options ?? {}, "config")
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { callTui, TuiRoute } from "./tui"
|
||||||
import { Permission } from "../permission"
|
import { Permission } from "../permission"
|
||||||
import { lazy } from "../util/lazy"
|
import { lazy } from "../util/lazy"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
|
import { Auth } from "../auth"
|
||||||
|
|
||||||
const ERRORS = {
|
const ERRORS = {
|
||||||
400: {
|
400: {
|
||||||
|
@ -1120,6 +1121,37 @@ export namespace Server {
|
||||||
async (c) => c.json(await callTui(c)),
|
async (c) => c.json(await callTui(c)),
|
||||||
)
|
)
|
||||||
.route("/tui/control", TuiRoute)
|
.route("/tui/control", TuiRoute)
|
||||||
|
.put(
|
||||||
|
"/auth/:id",
|
||||||
|
describeRoute({
|
||||||
|
description: "Set authentication credentials",
|
||||||
|
operationId: "auth.set",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully set authentication credentials",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...ERRORS,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zValidator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
zValidator("json", Auth.Info),
|
||||||
|
async (c) => {
|
||||||
|
const id = c.req.valid("param").id
|
||||||
|
const info = c.req.valid("json")
|
||||||
|
await Auth.set(id, info)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,4 +1,14 @@
|
||||||
import type { Event, createOpencodeClient, App, Model, Provider, Permission, UserMessage, Part } from "@opencode-ai/sdk"
|
import type {
|
||||||
|
Event,
|
||||||
|
createOpencodeClient,
|
||||||
|
App,
|
||||||
|
Model,
|
||||||
|
Provider,
|
||||||
|
Permission,
|
||||||
|
UserMessage,
|
||||||
|
Part,
|
||||||
|
Auth,
|
||||||
|
} from "@opencode-ai/sdk"
|
||||||
import type { BunShell } from "./shell"
|
import type { BunShell } from "./shell"
|
||||||
|
|
||||||
export type PluginInput = {
|
export type PluginInput = {
|
||||||
|
@ -10,6 +20,49 @@ export type Plugin = (input: PluginInput) => Promise<Hooks>
|
||||||
|
|
||||||
export interface Hooks {
|
export interface Hooks {
|
||||||
event?: (input: { event: Event }) => Promise<void>
|
event?: (input: { event: Event }) => Promise<void>
|
||||||
|
auth?: {
|
||||||
|
provider: string
|
||||||
|
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
|
||||||
|
methods: (
|
||||||
|
| {
|
||||||
|
type: "oauth"
|
||||||
|
label: string
|
||||||
|
authorize(): Promise<
|
||||||
|
{ url: string; instructions: string } & (
|
||||||
|
| {
|
||||||
|
method: "auto"
|
||||||
|
callback(): Promise<
|
||||||
|
| {
|
||||||
|
type: "success"
|
||||||
|
refresh: string
|
||||||
|
access: string
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "failed"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
method: "code"
|
||||||
|
callback(code: string): Promise<
|
||||||
|
| {
|
||||||
|
type: "success"
|
||||||
|
refresh: string
|
||||||
|
access: string
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "failed"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
)
|
||||||
|
>
|
||||||
|
}
|
||||||
|
| { type: "api"; label: string }
|
||||||
|
)[]
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Called when a new message is received
|
* Called when a new message is received
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -77,6 +77,9 @@ import type {
|
||||||
TuiClearPromptResponses,
|
TuiClearPromptResponses,
|
||||||
TuiExecuteCommandData,
|
TuiExecuteCommandData,
|
||||||
TuiExecuteCommandResponses,
|
TuiExecuteCommandResponses,
|
||||||
|
AuthSetData,
|
||||||
|
AuthSetResponses,
|
||||||
|
AuthSetErrors,
|
||||||
} from "./types.gen.js"
|
} from "./types.gen.js"
|
||||||
import { client as _heyApiClient } from "./client.gen.js"
|
import { client as _heyApiClient } from "./client.gen.js"
|
||||||
|
|
||||||
|
@ -517,6 +520,22 @@ class Tui extends _HeyApiClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Auth extends _HeyApiClient {
|
||||||
|
/**
|
||||||
|
* Set authentication credentials
|
||||||
|
*/
|
||||||
|
public set<ThrowOnError extends boolean = false>(options: Options<AuthSetData, ThrowOnError>) {
|
||||||
|
return (options.client ?? this._client).put<AuthSetResponses, AuthSetErrors, ThrowOnError>({
|
||||||
|
url: "/auth/{id}",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class OpencodeClient extends _HeyApiClient {
|
export class OpencodeClient extends _HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* Respond to a permission request
|
* Respond to a permission request
|
||||||
|
@ -544,4 +563,5 @@ export class OpencodeClient extends _HeyApiClient {
|
||||||
find = new Find({ client: this._client })
|
find = new Find({ client: this._client })
|
||||||
file = new File({ client: this._client })
|
file = new File({ client: this._client })
|
||||||
tui = new Tui({ client: this._client })
|
tui = new Tui({ client: this._client })
|
||||||
|
auth = new Auth({ client: this._client })
|
||||||
}
|
}
|
||||||
|
|
|
@ -1105,6 +1105,35 @@ export type Agent = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Auth =
|
||||||
|
| ({
|
||||||
|
type: "oauth"
|
||||||
|
} & OAuth)
|
||||||
|
| ({
|
||||||
|
type: "api"
|
||||||
|
} & ApiAuth)
|
||||||
|
| ({
|
||||||
|
type: "wellknown"
|
||||||
|
} & WellKnownAuth)
|
||||||
|
|
||||||
|
export type OAuth = {
|
||||||
|
type: "oauth"
|
||||||
|
refresh: string
|
||||||
|
access: string
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiAuth = {
|
||||||
|
type: "api"
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WellKnownAuth = {
|
||||||
|
type: "wellknown"
|
||||||
|
key: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
export type EventSubscribeData = {
|
export type EventSubscribeData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
@ -1858,6 +1887,33 @@ export type TuiExecuteCommandResponses = {
|
||||||
|
|
||||||
export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses]
|
export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses]
|
||||||
|
|
||||||
|
export type AuthSetData = {
|
||||||
|
body?: Auth
|
||||||
|
path: {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
query?: never
|
||||||
|
url: "/auth/{id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthSetErrors = {
|
||||||
|
/**
|
||||||
|
* Bad request
|
||||||
|
*/
|
||||||
|
400: _Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]
|
||||||
|
|
||||||
|
export type AuthSetResponses = {
|
||||||
|
/**
|
||||||
|
* Successfully set authentication credentials
|
||||||
|
*/
|
||||||
|
200: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]
|
||||||
|
|
||||||
export type ClientOptions = {
|
export type ClientOptions = {
|
||||||
baseUrl: `${string}://${string}` | (string & {})
|
baseUrl: `${string}://${string}` | (string & {})
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue