diff --git a/opencode.json b/opencode.json index 720ece5c..cbddbd3b 100644 --- a/opencode.json +++ b/opencode.json @@ -1,3 +1,37 @@ { - "$schema": "https://opencode.ai/config.json" + "$schema": "https://opencode.ai/config.json", + "provider": { + "github-copilot": { + "npm": "@ai-sdk/openai-compatible", + "options": { + "baseURL": "https://api.githubcopilot.com" + }, + "models": { + "gpt-4o": { + "name": "gpt-4o" + }, + "gpt-4.1": { + "name": "gpt-4.1" + }, + "claude-sonnet-4": { + "name": "claude-sonnet-4" + }, + "claude-3.7-sonnet": { + "name": "claude-3.7-sonnet" + }, + "o1": { + "name": "o1" + }, + "o3-mini": { + "name": "o3-mini" + }, + "gemini-2.5-pro": { + "name": "gemini-2.5-pro" + }, + "gemini-2.0-flash-001": { + "name": "gemini-2.0-flash-001" + } + } + } + } } diff --git a/packages/opencode/src/auth/github-copilot.ts b/packages/opencode/src/auth/github-copilot.ts index b3967b04..0b7cc54f 100644 --- a/packages/opencode/src/auth/github-copilot.ts +++ b/packages/opencode/src/auth/github-copilot.ts @@ -1,4 +1,6 @@ +import { z } from "zod" import { Auth } from "./index" +import { NamedError } from "../util/error" export namespace AuthGithubCopilot { const CLIENT_ID = "Iv1.b507a08c87ecfe98" @@ -33,7 +35,7 @@ export namespace AuthGithubCopilot { const deviceResponse = await fetch(DEVICE_CODE_URL, { method: "POST", headers: { - "Accept": "application/json", + Accept: "application/json", "Content-Type": "application/json", "User-Agent": "GithubCopilot/1.155.0", }, @@ -42,146 +44,111 @@ export namespace AuthGithubCopilot { scope: "read:user", }), }) - - if (!deviceResponse.ok) { - throw new DeviceCodeError("Failed to get device code") - } - const deviceData: DeviceCodeResponse = await deviceResponse.json() - return { - device_code: deviceData.device_code, - user_code: deviceData.user_code, - verification_uri: deviceData.verification_uri, + device: deviceData.device_code, + user: deviceData.user_code, + verification: deviceData.verification_uri, interval: deviceData.interval || 5, - expires_in: deviceData.expires_in, + expiry: deviceData.expires_in, } } - export async function pollForToken(device_code: string, interval: number = 5, maxAttempts: number = 36) { - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const response = await fetch(ACCESS_TOKEN_URL, { - method: "POST", - headers: { - "Accept": "application/json", - "Content-Type": "application/json", - "User-Agent": "GithubCopilot/1.155.0", - }, - body: JSON.stringify({ - client_id: CLIENT_ID, - device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), + export async function poll(device_code: string) { + const response = await fetch(ACCESS_TOKEN_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": "GithubCopilot/1.155.0", + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }) + + if (!response.ok) return "failed" + + const data: AccessTokenResponse = await response.json() + + if (data.access_token) { + // Store the GitHub OAuth token + await Auth.set("github-copilot", { + type: "oauth", + refresh: data.access_token, + access: "", + expires: 0, }) - - if (!response.ok) { - throw new TokenExchangeError("Failed to poll for access token") - } - - const data: AccessTokenResponse = await response.json() - - if (data.access_token) { - // Store the GitHub OAuth token - await Auth.set("github-copilot-oauth", { - type: "api", - key: data.access_token, - }) - return data.access_token - } - - if (data.error === "authorization_pending") { - await new Promise(resolve => setTimeout(resolve, interval * 1000)) - continue - } - - if (data.error) { - throw new TokenExchangeError(`OAuth error: ${data.error}`) - } + return "complete" } - throw new TokenExchangeError("Polling timeout exceeded") + if (data.error === "authorization_pending") return "pending" + + if (data.error) return "failed" + + return "pending" } - export async function getCopilotApiToken() { - const oauthInfo = await Auth.get("github-copilot-oauth") - if (!oauthInfo || oauthInfo.type !== "api") { - throw new AuthenticationError("No GitHub OAuth token found") - } - - // Check if we have a cached Copilot API token that's still valid - const copilotInfo = await Auth.get("github-copilot") - if (copilotInfo && copilotInfo.type === "oauth" && copilotInfo.expires > Date.now()) { - return { - token: copilotInfo.access, - apiEndpoint: "https://api.githubcopilot.com", - } - } + export async function access() { + const info = await Auth.get("github-copilot") + if (!info || info.type !== "oauth") return + if (info.access && info.expires > Date.now()) + return { access: info.access, api: "https://api.githubcopilot.com" } // Get new Copilot API token const response = await fetch(COPILOT_API_KEY_URL, { headers: { - "Accept": "application/json", - "Authorization": `Bearer ${oauthInfo.key}`, + Accept: "application/json", + Authorization: `Bearer ${info.refresh}`, "User-Agent": "GithubCopilot/1.155.0", "Editor-Version": "vscode/1.85.1", "Editor-Plugin-Version": "copilot/1.155.0", }, }) - if (!response.ok) { - throw new CopilotTokenError("Failed to get Copilot API token") - } + if (!response.ok) return const tokenData: CopilotTokenResponse = await response.json() // Store the Copilot API token await Auth.set("github-copilot", { type: "oauth", - refresh: "", // GitHub Copilot doesn't use refresh tokens + refresh: info.refresh, access: tokenData.token, - expires: tokenData.expires_at * 1000, // Convert to milliseconds + expires: tokenData.expires_at * 1000, }) return { - token: tokenData.token, - apiEndpoint: tokenData.endpoints.api, + access: tokenData.token, + api: tokenData.endpoints.api, } } - export async function access() { - try { - const result = await getCopilotApiToken() - return result.token - } catch (error) { - return null - } - } + export const DeviceCodeError = NamedError.create( + "DeviceCodeError", + z.object({}), + ) - export class DeviceCodeError extends Error { - constructor(message: string) { - super(message) - this.name = "DeviceCodeError" - } - } + export const TokenExchangeError = NamedError.create( + "TokenExchangeError", + z.object({ + message: z.string(), + }), + ) - export class TokenExchangeError extends Error { - constructor(message: string) { - super(message) - this.name = "TokenExchangeError" - } - } + export const AuthenticationError = NamedError.create( + "AuthenticationError", + z.object({ + message: z.string(), + }), + ) - export class AuthenticationError extends Error { - constructor(message: string) { - super(message) - this.name = "AuthenticationError" - } - } - - export class CopilotTokenError extends Error { - constructor(message: string) { - super(message) - this.name = "CopilotTokenError" - } - } + export const CopilotTokenError = NamedError.create( + "CopilotTokenError", + z.object({ + message: z.string(), + }), + ) } diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 84953574..0b8809ed 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -156,27 +156,29 @@ export const AuthLoginCommand = cmd({ await new Promise((resolve) => setTimeout(resolve, 10)) const deviceInfo = await AuthGithubCopilot.authorize() - prompts.note(`Please visit: ${deviceInfo.verification_uri}`) - prompts.note(`Enter code: ${deviceInfo.user_code}`) + prompts.note( + `Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`, + ) - const confirm = await prompts.confirm({ - message: "Press Enter after completing authentication in browser", - }) + const spinner = prompts.spinner() + spinner.start("Waiting for authorization...") - if (prompts.isCancel(confirm)) throw new UI.CancelledError() - - try { - await AuthGithubCopilot.pollForToken( - deviceInfo.device_code, - deviceInfo.interval, - ) - await AuthGithubCopilot.getCopilotApiToken() - prompts.log.success("GitHub Copilot login successful") - } catch (error) { - prompts.log.error( - `Login failed: ${error instanceof Error ? error.message : String(error)}`, + while (true) { + await new Promise((resolve) => + setTimeout(resolve, deviceInfo.interval * 1000), ) + const status = await AuthGithubCopilot.poll(deviceInfo.device) + if (status === "pending") continue + if (status === "complete") { + spinner.stop("Login successful") + break + } + if (status === "failed") { + spinner.stop("Failed to authorize", 1) + break + } } + prompts.outro("Done") return } diff --git a/packages/opencode/src/cli/cmd/login-anthropic.ts b/packages/opencode/src/cli/cmd/login-anthropic.ts deleted file mode 100644 index 64df8beb..00000000 --- a/packages/opencode/src/cli/cmd/login-anthropic.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { AuthAnthropic } from "../../auth/anthropic" -import { UI } from "../ui" - -// Example: https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=MdFtFgFap23AWDSN0oa3-eaKjQRFE4CaEhXx8M9fHZg&code_challenge_method=S256&state=rKLtaDzm88GSwekyEqdi0wXX-YqIr13tSzYymSzpvfs - -export const LoginAnthropicCommand = { - command: "anthropic", - describe: "Login to Anthropic", - handler: async () => { - const { url, verifier } = await AuthAnthropic.authorize() - - UI.println("Login to Anthropic") - UI.println("Open the following URL in your browser:") - UI.println(url) - UI.println("") - - const code = await UI.input("Paste the authorization code here: ") - await AuthAnthropic.exchange(code, verifier) - }, -} diff --git a/packages/opencode/src/cli/cmd/login-github-copilot.ts b/packages/opencode/src/cli/cmd/login-github-copilot.ts deleted file mode 100644 index 2e2af2d8..00000000 --- a/packages/opencode/src/cli/cmd/login-github-copilot.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AuthGithubCopilot } from "../../auth/github-copilot" -import { UI } from "../ui" - -export const LoginGithubCopilotCommand = { - command: "github-copilot", - describe: "Login to GitHub Copilot", - handler: async () => { - const deviceInfo = await AuthGithubCopilot.authorize() - - UI.println("Login to GitHub Copilot") - UI.println("Open the following URL in your browser:") - UI.println(deviceInfo.verification_uri) - UI.println("") - UI.println(`Enter code: ${deviceInfo.user_code}`) - UI.println("") - - await UI.input("Press Enter after completing authentication in browser: ") - - UI.println("Waiting for authorization...") - await AuthGithubCopilot.pollForToken( - deviceInfo.device_code, - deviceInfo.interval, - ) - - await AuthGithubCopilot.getCopilotApiToken() - UI.println("✅ Login successful!") - }, -} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index dbddcf59..74577c55 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -67,13 +67,10 @@ export namespace Provider { }, } }, - async "github-copilot"(provider) { - const tokenResult = await AuthGithubCopilot.getCopilotApiToken() - if (!tokenResult) return false + "github-copilot": async (provider) => { + const info = await AuthGithubCopilot.access() + if (!info) return false - const { apiEndpoint } = tokenResult - - // If provider exists (from models.dev), set costs to 0 if (provider && provider.models) { for (const model of Object.values(provider.models)) { model.cost = { @@ -86,23 +83,18 @@ export namespace Provider { return { options: { apiKey: "", - baseURL: apiEndpoint, + baseURL: info.api, async fetch(input: any, init: any) { - const currentToken = await AuthGithubCopilot.access() - if (!currentToken) { - throw new Error("GitHub Copilot authentication expired") - } - + const token = await AuthGithubCopilot.access() + if (!token) throw new Error("GitHub Copilot authentication expired") const headers = { ...init.headers, - Authorization: `Bearer ${currentToken}`, + Authorization: `Bearer ${token.access}`, "User-Agent": "GithubCopilot/1.155.0", "Editor-Version": "vscode/1.85.1", "Editor-Plugin-Version": "copilot/1.155.0", } - delete headers["x-api-key"] - return fetch(input, { ...init, headers,