Moves effect logging, observability, runtime utilities, flags, installation version info, and process utilities from opencode to core package. This enables better code sharing across packages and establishes core as the single source of truth for foundational utilities. All internal imports updated to use @opencode-ai/core paths for consistency.
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
|
import type { Model } from "@opencode-ai/sdk/v2"
|
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
|
import { iife } from "@/util/iife"
|
|
import { Log } from "../../util"
|
|
import { setTimeout as sleep } from "node:timers/promises"
|
|
import { CopilotModels } from "./models"
|
|
import { MessageV2 } from "@/session/message-v2"
|
|
|
|
const log = Log.create({ service: "plugin.copilot" })
|
|
|
|
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
|
// Add a small safety buffer when polling to avoid hitting the server
|
|
// slightly too early due to clock skew / timer drift.
|
|
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 // 3 seconds
|
|
function normalizeDomain(url: string) {
|
|
return url.replace(/^https?:\/\//, "").replace(/\/$/, "")
|
|
}
|
|
|
|
function getUrls(domain: string) {
|
|
return {
|
|
DEVICE_CODE_URL: `https://${domain}/login/device/code`,
|
|
ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
|
|
}
|
|
}
|
|
|
|
function base(enterpriseUrl?: string) {
|
|
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
|
|
}
|
|
|
|
// Check if a message is a synthetic user msg used to attach an image from a tool call
|
|
function imgMsg(msg: any): boolean {
|
|
if (msg?.role !== "user") return false
|
|
|
|
// Handle the 3 api formats
|
|
|
|
const content = msg.content
|
|
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
|
|
if (!Array.isArray(content)) return false
|
|
return content.some(
|
|
(part: any) =>
|
|
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
|
|
)
|
|
}
|
|
|
|
function fix(model: Model, url: string): Model {
|
|
return {
|
|
...model,
|
|
api: {
|
|
...model.api,
|
|
url,
|
|
npm: "@ai-sdk/github-copilot",
|
|
},
|
|
}
|
|
}
|
|
|
|
export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|
const sdk = input.client
|
|
return {
|
|
provider: {
|
|
id: "github-copilot",
|
|
async models(provider, ctx) {
|
|
if (ctx.auth?.type !== "oauth") {
|
|
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model, base())]))
|
|
}
|
|
|
|
const auth = ctx.auth
|
|
|
|
return CopilotModels.get(
|
|
base(auth.enterpriseUrl),
|
|
{
|
|
Authorization: `Bearer ${auth.refresh}`,
|
|
"User-Agent": `opencode/${InstallationVersion}`,
|
|
},
|
|
provider.models,
|
|
).catch((error) => {
|
|
log.error("failed to fetch copilot models", { error })
|
|
return Object.fromEntries(
|
|
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
|
|
)
|
|
})
|
|
},
|
|
},
|
|
auth: {
|
|
provider: "github-copilot",
|
|
async loader(getAuth) {
|
|
const info = await getAuth()
|
|
if (!info || info.type !== "oauth") return {}
|
|
|
|
return {
|
|
apiKey: "",
|
|
async fetch(request: RequestInfo | URL, init?: RequestInit) {
|
|
const info = await getAuth()
|
|
if (info.type !== "oauth") return fetch(request, init)
|
|
|
|
const url = request instanceof URL ? request.href : typeof request === "string" ? request : request.url
|
|
const { isVision, isAgent } = iife(() => {
|
|
try {
|
|
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body
|
|
|
|
// Completions API
|
|
if (body?.messages && url.includes("completions")) {
|
|
const last = body.messages[body.messages.length - 1]
|
|
return {
|
|
isVision: body.messages.some(
|
|
(msg: any) =>
|
|
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
|
),
|
|
isAgent: last?.role !== "user" || imgMsg(last),
|
|
}
|
|
}
|
|
|
|
// Responses API
|
|
if (body?.input) {
|
|
const last = body.input[body.input.length - 1]
|
|
return {
|
|
isVision: body.input.some(
|
|
(item: any) =>
|
|
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
|
|
),
|
|
isAgent: last?.role !== "user" || imgMsg(last),
|
|
}
|
|
}
|
|
|
|
// Messages API
|
|
if (body?.messages) {
|
|
const last = body.messages[body.messages.length - 1]
|
|
const hasNonToolCalls =
|
|
Array.isArray(last?.content) && last.content.some((part: any) => part?.type !== "tool_result")
|
|
return {
|
|
isVision: body.messages.some(
|
|
(item: any) =>
|
|
Array.isArray(item?.content) &&
|
|
item.content.some(
|
|
(part: any) =>
|
|
part?.type === "image" ||
|
|
// images can be nested inside tool_result content
|
|
(part?.type === "tool_result" &&
|
|
Array.isArray(part?.content) &&
|
|
part.content.some((nested: any) => nested?.type === "image")),
|
|
),
|
|
),
|
|
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last),
|
|
}
|
|
}
|
|
} catch {}
|
|
return { isVision: false, isAgent: false }
|
|
})
|
|
|
|
const headers: Record<string, string> = {
|
|
"x-initiator": isAgent ? "agent" : "user",
|
|
...(init?.headers as Record<string, string>),
|
|
"User-Agent": `opencode/${InstallationVersion}`,
|
|
Authorization: `Bearer ${info.refresh}`,
|
|
"Openai-Intent": "conversation-edits",
|
|
}
|
|
|
|
if (isVision) {
|
|
headers["Copilot-Vision-Request"] = "true"
|
|
}
|
|
|
|
delete headers["x-api-key"]
|
|
delete headers["authorization"]
|
|
|
|
return fetch(request, {
|
|
...init,
|
|
headers,
|
|
})
|
|
},
|
|
}
|
|
},
|
|
methods: [
|
|
{
|
|
type: "oauth",
|
|
label: "Login with GitHub Copilot",
|
|
prompts: [
|
|
{
|
|
type: "select",
|
|
key: "deploymentType",
|
|
message: "Select GitHub deployment type",
|
|
options: [
|
|
{
|
|
label: "GitHub.com",
|
|
value: "github.com",
|
|
hint: "Public",
|
|
},
|
|
{
|
|
label: "GitHub Enterprise",
|
|
value: "enterprise",
|
|
hint: "Data residency or self-hosted",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "text",
|
|
key: "enterpriseUrl",
|
|
message: "Enter your GitHub Enterprise URL or domain",
|
|
placeholder: "company.ghe.com or https://company.ghe.com",
|
|
when: { key: "deploymentType", op: "eq", value: "enterprise" },
|
|
validate: (value) => {
|
|
if (!value) return "URL or domain is required"
|
|
try {
|
|
const url = value.includes("://") ? new URL(value) : new URL(`https://${value}`)
|
|
if (!url.hostname) return "Please enter a valid URL or domain"
|
|
return undefined
|
|
} catch {
|
|
return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)"
|
|
}
|
|
},
|
|
},
|
|
],
|
|
async authorize(inputs = {}) {
|
|
const deploymentType = inputs.deploymentType || "github.com"
|
|
|
|
let domain = "github.com"
|
|
|
|
if (deploymentType === "enterprise") {
|
|
const enterpriseUrl = inputs.enterpriseUrl
|
|
domain = normalizeDomain(enterpriseUrl!)
|
|
}
|
|
|
|
const urls = getUrls(domain)
|
|
|
|
const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": `opencode/${InstallationVersion}`,
|
|
},
|
|
body: JSON.stringify({
|
|
client_id: CLIENT_ID,
|
|
scope: "read:user",
|
|
}),
|
|
})
|
|
|
|
if (!deviceResponse.ok) {
|
|
throw new Error("Failed to initiate device authorization")
|
|
}
|
|
|
|
const deviceData = (await deviceResponse.json()) as {
|
|
verification_uri: string
|
|
user_code: string
|
|
device_code: string
|
|
interval: number
|
|
}
|
|
|
|
return {
|
|
url: deviceData.verification_uri,
|
|
instructions: `Enter code: ${deviceData.user_code}`,
|
|
method: "auto" as const,
|
|
async callback() {
|
|
while (true) {
|
|
const response = await fetch(urls.ACCESS_TOKEN_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": `opencode/${InstallationVersion}`,
|
|
},
|
|
body: JSON.stringify({
|
|
client_id: CLIENT_ID,
|
|
device_code: deviceData.device_code,
|
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) return { type: "failed" as const }
|
|
|
|
const data = (await response.json()) as {
|
|
access_token?: string
|
|
error?: string
|
|
interval?: number
|
|
}
|
|
|
|
if (data.access_token) {
|
|
const result: {
|
|
type: "success"
|
|
refresh: string
|
|
access: string
|
|
expires: number
|
|
provider?: string
|
|
enterpriseUrl?: string
|
|
} = {
|
|
type: "success",
|
|
refresh: data.access_token,
|
|
access: data.access_token,
|
|
expires: 0,
|
|
}
|
|
|
|
if (deploymentType === "enterprise") {
|
|
result.enterpriseUrl = domain
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
if (data.error === "authorization_pending") {
|
|
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
|
continue
|
|
}
|
|
|
|
if (data.error === "slow_down") {
|
|
// Based on the RFC spec, we must add 5 seconds to our current polling interval.
|
|
// (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5)
|
|
let newInterval = (deviceData.interval + 5) * 1000
|
|
|
|
// GitHub OAuth API may return the new interval in seconds in the response.
|
|
// We should try to use that if provided with safety margin.
|
|
const serverInterval = data.interval
|
|
if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) {
|
|
newInterval = serverInterval * 1000
|
|
}
|
|
|
|
await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
|
continue
|
|
}
|
|
|
|
if (data.error) return { type: "failed" as const }
|
|
|
|
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
|
continue
|
|
}
|
|
},
|
|
}
|
|
},
|
|
},
|
|
],
|
|
},
|
|
"chat.params": async (incoming, output) => {
|
|
if (!incoming.model.providerID.includes("github-copilot")) return
|
|
|
|
// Match github copilot cli, omit maxOutputTokens for gpt models
|
|
if (incoming.model.api.id.includes("gpt")) {
|
|
output.maxOutputTokens = undefined
|
|
}
|
|
|
|
// GitHub Copilot's /v1/messages shim rejects the GA `eager_input_streaming`
|
|
// field on tool definitions ("Extra inputs are not permitted"). Opt out of
|
|
// the @ai-sdk/anthropic default so it stops injecting the field.
|
|
if (incoming.model.api.npm === "@ai-sdk/anthropic") {
|
|
output.options.toolStreaming = false
|
|
}
|
|
},
|
|
"chat.headers": async (incoming, output) => {
|
|
if (!incoming.model.providerID.includes("github-copilot")) return
|
|
|
|
if (incoming.model.api.npm === "@ai-sdk/anthropic") {
|
|
output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
|
|
}
|
|
|
|
const parts = await sdk.session
|
|
.message({
|
|
path: {
|
|
id: incoming.message.sessionID,
|
|
messageID: incoming.message.id,
|
|
},
|
|
query: {
|
|
directory: input.directory,
|
|
},
|
|
throwOnError: true,
|
|
})
|
|
.catch(() => undefined)
|
|
|
|
if (
|
|
parts?.data.parts?.some(
|
|
(part) =>
|
|
part.type === "compaction" ||
|
|
// Auto-compaction resumes via a synthetic user text part. Treat only
|
|
// that marked followup as agent-initiated so manual prompts stay user-initiated.
|
|
(part.type === "text" && part.synthetic && part.metadata?.compaction_continue === true),
|
|
)
|
|
) {
|
|
output.headers["x-initiator"] = "agent"
|
|
return
|
|
}
|
|
|
|
const session = await sdk.session
|
|
.get({
|
|
path: {
|
|
id: incoming.sessionID,
|
|
},
|
|
query: {
|
|
directory: input.directory,
|
|
},
|
|
throwOnError: true,
|
|
})
|
|
.catch(() => undefined)
|
|
if (!session || !session.data.parentID) return
|
|
// mark subagent sessions as agent initiated matching standard that other copilot tools have
|
|
output.headers["x-initiator"] = "agent"
|
|
},
|
|
}
|
|
}
|