This commit is contained in:
Dax Raad 2025-06-22 18:22:46 -04:00
parent 63c504f086
commit 5e46d98c86
6 changed files with 134 additions and 187 deletions

View file

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

View file

@ -1,4 +1,6 @@
import { z } from "zod"
import { Auth } from "./index" import { Auth } from "./index"
import { NamedError } from "../util/error"
export namespace AuthGithubCopilot { export namespace AuthGithubCopilot {
const CLIENT_ID = "Iv1.b507a08c87ecfe98" const CLIENT_ID = "Iv1.b507a08c87ecfe98"
@ -33,7 +35,7 @@ export namespace AuthGithubCopilot {
const deviceResponse = await fetch(DEVICE_CODE_URL, { const deviceResponse = await fetch(DEVICE_CODE_URL, {
method: "POST", method: "POST",
headers: { headers: {
"Accept": "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "GithubCopilot/1.155.0", "User-Agent": "GithubCopilot/1.155.0",
}, },
@ -42,146 +44,111 @@ export namespace AuthGithubCopilot {
scope: "read:user", scope: "read:user",
}), }),
}) })
if (!deviceResponse.ok) {
throw new DeviceCodeError("Failed to get device code")
}
const deviceData: DeviceCodeResponse = await deviceResponse.json() const deviceData: DeviceCodeResponse = await deviceResponse.json()
return { return {
device_code: deviceData.device_code, device: deviceData.device_code,
user_code: deviceData.user_code, user: deviceData.user_code,
verification_uri: deviceData.verification_uri, verification: deviceData.verification_uri,
interval: deviceData.interval || 5, 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) { export async function poll(device_code: string) {
for (let attempt = 0; attempt < maxAttempts; attempt++) { const response = await fetch(ACCESS_TOKEN_URL, {
const response = await fetch(ACCESS_TOKEN_URL, { method: "POST",
method: "POST", headers: {
headers: { Accept: "application/json",
"Accept": "application/json", "Content-Type": "application/json",
"Content-Type": "application/json", "User-Agent": "GithubCopilot/1.155.0",
"User-Agent": "GithubCopilot/1.155.0", },
}, body: JSON.stringify({
body: JSON.stringify({ client_id: CLIENT_ID,
client_id: CLIENT_ID, device_code,
device_code, grant_type: "urn:ietf:params:oauth:grant-type: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,
}) })
return "complete"
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}`)
}
} }
throw new TokenExchangeError("Polling timeout exceeded") if (data.error === "authorization_pending") return "pending"
if (data.error) return "failed"
return "pending"
} }
export async function getCopilotApiToken() { export async function access() {
const oauthInfo = await Auth.get("github-copilot-oauth") const info = await Auth.get("github-copilot")
if (!oauthInfo || oauthInfo.type !== "api") { if (!info || info.type !== "oauth") return
throw new AuthenticationError("No GitHub OAuth token found") if (info.access && info.expires > Date.now())
} return { access: info.access, api: "https://api.githubcopilot.com" }
// 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",
}
}
// Get new Copilot API token // Get new Copilot API token
const response = await fetch(COPILOT_API_KEY_URL, { const response = await fetch(COPILOT_API_KEY_URL, {
headers: { headers: {
"Accept": "application/json", Accept: "application/json",
"Authorization": `Bearer ${oauthInfo.key}`, Authorization: `Bearer ${info.refresh}`,
"User-Agent": "GithubCopilot/1.155.0", "User-Agent": "GithubCopilot/1.155.0",
"Editor-Version": "vscode/1.85.1", "Editor-Version": "vscode/1.85.1",
"Editor-Plugin-Version": "copilot/1.155.0", "Editor-Plugin-Version": "copilot/1.155.0",
}, },
}) })
if (!response.ok) { if (!response.ok) return
throw new CopilotTokenError("Failed to get Copilot API token")
}
const tokenData: CopilotTokenResponse = await response.json() const tokenData: CopilotTokenResponse = await response.json()
// Store the Copilot API token // Store the Copilot API token
await Auth.set("github-copilot", { await Auth.set("github-copilot", {
type: "oauth", type: "oauth",
refresh: "", // GitHub Copilot doesn't use refresh tokens refresh: info.refresh,
access: tokenData.token, access: tokenData.token,
expires: tokenData.expires_at * 1000, // Convert to milliseconds expires: tokenData.expires_at * 1000,
}) })
return { return {
token: tokenData.token, access: tokenData.token,
apiEndpoint: tokenData.endpoints.api, api: tokenData.endpoints.api,
} }
} }
export async function access() { export const DeviceCodeError = NamedError.create(
try { "DeviceCodeError",
const result = await getCopilotApiToken() z.object({}),
return result.token )
} catch (error) {
return null
}
}
export class DeviceCodeError extends Error { export const TokenExchangeError = NamedError.create(
constructor(message: string) { "TokenExchangeError",
super(message) z.object({
this.name = "DeviceCodeError" message: z.string(),
} }),
} )
export class TokenExchangeError extends Error { export const AuthenticationError = NamedError.create(
constructor(message: string) { "AuthenticationError",
super(message) z.object({
this.name = "TokenExchangeError" message: z.string(),
} }),
} )
export class AuthenticationError extends Error { export const CopilotTokenError = NamedError.create(
constructor(message: string) { "CopilotTokenError",
super(message) z.object({
this.name = "AuthenticationError" message: z.string(),
} }),
} )
export class CopilotTokenError extends Error {
constructor(message: string) {
super(message)
this.name = "CopilotTokenError"
}
}
} }

View file

@ -156,27 +156,29 @@ export const AuthLoginCommand = cmd({
await new Promise((resolve) => setTimeout(resolve, 10)) await new Promise((resolve) => setTimeout(resolve, 10))
const deviceInfo = await AuthGithubCopilot.authorize() const deviceInfo = await AuthGithubCopilot.authorize()
prompts.note(`Please visit: ${deviceInfo.verification_uri}`) prompts.note(
prompts.note(`Enter code: ${deviceInfo.user_code}`) `Please visit: ${deviceInfo.verification}\nEnter code: ${deviceInfo.user}`,
)
const confirm = await prompts.confirm({ const spinner = prompts.spinner()
message: "Press Enter after completing authentication in browser", spinner.start("Waiting for authorization...")
})
if (prompts.isCancel(confirm)) throw new UI.CancelledError() while (true) {
await new Promise((resolve) =>
try { setTimeout(resolve, deviceInfo.interval * 1000),
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)}`,
) )
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") prompts.outro("Done")
return return
} }

View file

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

View file

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

View file

@ -67,13 +67,10 @@ export namespace Provider {
}, },
} }
}, },
async "github-copilot"(provider) { "github-copilot": async (provider) => {
const tokenResult = await AuthGithubCopilot.getCopilotApiToken() const info = await AuthGithubCopilot.access()
if (!tokenResult) return false if (!info) return false
const { apiEndpoint } = tokenResult
// If provider exists (from models.dev), set costs to 0
if (provider && provider.models) { if (provider && provider.models) {
for (const model of Object.values(provider.models)) { for (const model of Object.values(provider.models)) {
model.cost = { model.cost = {
@ -86,23 +83,18 @@ export namespace Provider {
return { return {
options: { options: {
apiKey: "", apiKey: "",
baseURL: apiEndpoint, baseURL: info.api,
async fetch(input: any, init: any) { async fetch(input: any, init: any) {
const currentToken = await AuthGithubCopilot.access() const token = await AuthGithubCopilot.access()
if (!currentToken) { if (!token) throw new Error("GitHub Copilot authentication expired")
throw new Error("GitHub Copilot authentication expired")
}
const headers = { const headers = {
...init.headers, ...init.headers,
Authorization: `Bearer ${currentToken}`, Authorization: `Bearer ${token.access}`,
"User-Agent": "GithubCopilot/1.155.0", "User-Agent": "GithubCopilot/1.155.0",
"Editor-Version": "vscode/1.85.1", "Editor-Version": "vscode/1.85.1",
"Editor-Plugin-Version": "copilot/1.155.0", "Editor-Plugin-Version": "copilot/1.155.0",
} }
delete headers["x-api-key"] delete headers["x-api-key"]
return fetch(input, { return fetch(input, {
...init, ...init,
headers, headers,