Files
opencode/packages/core/src/plugin/provider/github-copilot.ts
T

559 lines
23 KiB
TypeScript

import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import type { SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
import { IntegrationConnection } from "../../integration/connection.js"
import { Credential } from "../../credential.js"
import { Bus } from "../../bus.js"
import { CopilotModels } from "../../github-copilot/models.js"
import { App } from "../../app.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Agent } from "../../agent.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Provider } from "../../provider.js"
import { SessionAffinity } from "../../session/affinity.js"
import type { PluginInternal } from "../internal.js"
const clientID = "Ov23li8tweQw6odWQebz"
const apiVersion = "2026-08-01"
const userApiVersion = "2025-04-01"
const pollingSafetyMargin = 3000
const methodID = Integration.MethodID.make("device")
const Device = Schema.Struct({
verification_uri: Schema.String,
user_code: Schema.String,
device_code: Schema.String,
interval: Schema.Number,
})
const Token = Schema.Struct({
access_token: Schema.optional(Schema.String),
error: Schema.optional(Schema.String),
interval: Schema.optional(Schema.Number),
})
const User = Schema.Struct({
chat_enabled: Schema.optional(Schema.Boolean),
can_signup_for_limited: Schema.optional(Schema.Boolean),
endpoints: Schema.optional(
Schema.Struct({
api: Schema.optional(Schema.String),
}),
),
})
const decodeUser = Schema.decodeUnknownOption(User)
const JsonBody = Schema.fromJsonString(Schema.Unknown)
const decodeBody = Schema.decodeUnknownOption(JsonBody)
const oauth = (app: App.Info) =>
({
integrationID: Integration.ID.make("github-copilot"),
method: {
id: methodID,
type: "oauth",
label: "Login with GitHub Copilot",
form: [
{
type: "string",
key: "deploymentType",
title: "Select GitHub deployment type",
required: true,
options: [
{ label: "GitHub.com", value: "github.com", description: "Public" },
{ label: "GitHub Enterprise", value: "enterprise", description: "Data residency or self-hosted" },
],
},
{
type: "string",
key: "enterpriseUrl",
title: "Enter your GitHub Enterprise URL or domain",
placeholder: "company.ghe.com or https://company.ghe.com",
required: true,
when: [{ key: "deploymentType", op: "eq", value: "enterprise" }],
},
],
},
authorize: (answer) =>
Effect.gen(function* () {
const enterprise = answer.deploymentType === "enterprise"
const enterpriseUrl = typeof answer.enterpriseUrl === "string" ? answer.enterpriseUrl : undefined
if (enterprise && !enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required"))
const domain = enterprise ? normalizeDomain(enterpriseUrl ?? "") : "github.com"
const urls = oauthURLs(domain)
const device = yield* request(urls.device, {
method: "POST",
headers: headers(app),
body: JSON.stringify({ client_id: clientID, scope: "read:user" }),
}).pipe(Effect.map(Schema.decodeUnknownSync(Device)))
const interval = Math.max(device.interval, 1) * 1000
const poll = (wait: number): Effect.Effect<Credential.OAuth, unknown> =>
request(urls.token, {
method: "POST",
headers: headers(app),
body: JSON.stringify({
client_id: clientID,
device_code: device.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
}).pipe(
Effect.map(Schema.decodeUnknownSync(Token)),
Effect.flatMap((token) => {
if (token.access_token) {
const access = token.access_token
return request(
`${domain === "github.com" ? "https://api.github.com" : `https://api.${domain}`}/copilot_internal/user`,
{
headers: {
Accept: "application/json",
Authorization: `Bearer ${access}`,
"User-Agent": App.useragent(app),
"X-GitHub-Api-Version": userApiVersion,
},
},
).pipe(
Effect.map((user) => Option.getOrUndefined(decodeUser(user))),
// Only an explicit entitlement answer blocks login; a failed
// or malformed lookup must not turn a GitHub hiccup into a denial.
Effect.orElseSucceed(() => undefined),
Effect.flatMap((user) => {
const denied = user && copilotEntitlementError(user)
if (denied) return Effect.fail(new Error(denied))
const apiEndpoint = user?.endpoints?.api?.replace(/\/+$/, "")
return Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
refresh: access,
access,
expires: 0,
...((enterprise || apiEndpoint) && {
metadata: {
...(enterprise ? { enterpriseUrl: domain } : {}),
...(apiEndpoint ? { apiEndpoint } : {}),
},
}),
}),
)
}),
)
}
if (token.error === "authorization_pending")
return Effect.sleep(wait + pollingSafetyMargin).pipe(Effect.andThen(poll(wait)))
if (token.error === "slow_down") {
const next = token.interval && token.interval > 0 ? token.interval * 1000 : wait + 5000
return Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(poll(next)))
}
return Effect.fail(new Error(`Device authorization failed${token.error ? `: ${token.error}` : ""}`))
}),
)
return {
mode: "auto" as const,
url: device.verification_uri,
instructions: `Enter code: ${device.user_code}`,
callback: poll(interval),
}
}),
}) satisfies IntegrationOAuthMethodRegistration
export const GithubCopilotPlugin = define({
id: "opencode.provider.github.copilot",
effect: Effect.fn(function* (ctx) {
const providers = yield* Provider.Service
const bus = yield* Bus.Service
const loading = Semaphore.makeUnsafe(1)
const loaded: {
baseURL?: string
token?: string
models?: CopilotModels.Snapshot
connection?: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
} = {}
const load = Effect.fn("GithubCopilotPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("github-copilot")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth") {
loaded.baseURL = undefined
loaded.token = undefined
loaded.models = undefined
loaded.connection = undefined
return
}
const url = copilotBaseURL(credential.metadata) ?? baseURL()
const provider = yield* providers.get(Provider.ID.githubCopilot)
const remote = yield* Effect.tryPromise({
try: () =>
CopilotModels.load(url, {
...provider?.headers,
Authorization: `Bearer ${credential.refresh}`,
"User-Agent": App.useragent(ctx.app),
"X-GitHub-Api-Version": apiVersion,
}),
catch: (cause) => cause,
}).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to sync GitHub Copilot models", { cause }).pipe(Effect.as(undefined)),
),
)
if (
IntegrationConnection.key(connection) !==
IntegrationConnection.key(yield* ctx.integration.connection.active("github-copilot"))
)
return
loaded.baseURL = url
loaded.token = credential.refresh
loaded.models = remote
loaded.connection = connection
})
yield* ctx.integration.transform((editor) => {
editor.method.remove("github-copilot", { type: "key" })
editor.method.update(oauth(ctx.app))
})
yield* ctx.provider.transform((evt) => {
const item = evt.get(Provider.ID.githubCopilot)
if (!item) return
if (loaded.models) {
evt.add({
info: item.provider,
models: Array.from(
CopilotModels.derive(loaded.baseURL ?? baseURL(), loaded.models, Array.from(item.models.values())).values(),
),
sourceConnection: loaded.connection,
})
return
}
for (const id of item.models.keys()) {
evt.models.update(item.provider.id, id, (model) => {
model.package = Provider.aisdk("@ai-sdk/github-copilot")
if (loaded.baseURL) model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
})
}
})
yield* ctx.model.transform((models) => {
if (models.get(Provider.ID.githubCopilot, Model.ID.make("gpt-5-chat-latest"))) {
models.update(Provider.ID.githubCopilot, Model.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// so hide it only for Copilot rather than for every provider catalog.
model.enabled = false
})
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("github-copilot")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (evt.package !== "@ai-sdk/github-copilot") return
evt.options.fetch = copilotFetch(
typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined,
evt.options.fetch,
ctx.app,
)
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider.js"))
evt.sdk = mod.createOpenaiCompatible(evt.options)
}),
)
yield* ctx.session.hook(
"model.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
const session = yield* ctx.session
.get({ sessionID: evt.sessionID })
.pipe(Effect.orElseSucceed(() => undefined))
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
evt.headers["X-Interaction-Type"] = interaction
evt.headers["X-Interaction-Id"] = session ? SessionAffinity.get(session) : evt.sessionID
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
}),
{ providerID: Provider.ID.githubCopilot },
)
// GitHub's integration guide designates session naming as a utility scenario served by
// free, rate-limited utility models. Any failure leaves the result unset so the normal
// billable title path still runs.
yield* ctx.session.hook(
"title",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (!loaded.baseURL || !loaded.token || !loaded.models) return
const agent = yield* ctx.agent.get({ agentID: Agent.ID.make("title") }).pipe(Effect.orElseSucceed(() => undefined))
if (agent?.data.model) return
const model = utilityTitleModels.find((id) => loaded.models?.has(id))
if (!model) return
evt.result = yield* utilityTitle(
{ baseURL: loaded.baseURL, token: loaded.token, model, app: ctx.app },
evt,
).pipe(
Effect.tapError((cause) => Effect.logDebug("Copilot utility title failed", { model, cause })),
Effect.orElseSucceed(() => undefined),
)
}),
{ providerID: Provider.ID.githubCopilot },
)
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
const token = evt.request.headers.get("x-api-key")
if (!token) return
const text = yield* Effect.promise(() => evt.request.clone().text())
const body = Option.getOrUndefined(decodeBody(text))
applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true)
}),
{ providerID: Provider.ID.githubCopilot },
)
yield* ctx.aisdk.hook(
"language",
Effect.fn(function* (evt) {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
return
}
if (evt.options.endpoint === "responses" && evt.sdk.responses) {
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
return
}
if (evt.options.endpoint === "chat" && evt.sdk.chat) {
evt.language = evt.sdk.chat(evt.model.modelID ?? evt.model.id)
return
}
const id = evt.model.modelID ?? evt.model.id
// Copilot serves Grok, Gemini, and MAI Code only on /responses; advertised
// endpoint metadata above wins whenever the live model list provides it.
const gpt = /^gpt-(\d+)/.exec(id)
const responses =
(gpt !== null && Number(gpt[1]) >= 5 && !id.startsWith("gpt-5-mini")) ||
id.startsWith("grok-") ||
id.startsWith("gemini-") ||
id.startsWith("mai-code-")
evt.language = responses ? evt.sdk.responses(id) : evt.sdk.chat(id)
}),
)
}),
} satisfies PluginInternal.InternalPlugin)
function normalizeDomain(input: string) {
return input.replace(/^https?:\/\//, "").replace(/\/$/, "")
}
function oauthURLs(domain: string) {
return {
device: `https://${domain}/login/device/code`,
token: `https://${domain}/login/oauth/access_token`,
}
}
function baseURL(enterprise?: string) {
return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
}
// GitHub reports Copilot access on /copilot_internal/user; OAuth itself succeeds
// for any GitHub account, so this is the only signal that the account can chat.
export function copilotEntitlementError(user: { chat_enabled?: boolean; can_signup_for_limited?: boolean }) {
if (user.chat_enabled !== false) return
if (user.can_signup_for_limited)
return "This GitHub account is not signed up for GitHub Copilot. Sign up for Copilot Free at https://github.com/features/copilot/plans and connect again."
return "This GitHub account does not have GitHub Copilot access. It needs an active Copilot subscription or a seat assigned by an organization."
}
export function copilotBaseURL(metadata?: Readonly<Record<string, unknown>>) {
const endpoint = metadata?.apiEndpoint
if (typeof endpoint === "string" && endpoint) return endpoint
const enterprise = metadata?.enterpriseUrl
return baseURL(typeof enterprise === "string" ? enterprise : undefined)
}
function headers(app: App.Info) {
return {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": App.useragent(app),
}
}
function request(url: string, init: RequestInit) {
return Effect.tryPromise({
try: async (signal) => {
const response = await fetch(url, { ...init, signal })
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
return response.json()
},
catch: (cause) => cause,
})
}
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
// Matches the Copilot client: gpt-4o-mini is the "small utility" model; when an account
// lacks it the request falls through to the regular title path instead of another guess.
export const utilityTitleModels = ["gpt-4o-mini"]
const UtilityCompletion = Schema.Struct({
choices: Schema.Array(Schema.Struct({ message: Schema.Struct({ content: Schema.NullOr(Schema.String) }) })),
})
const decodeUtilityCompletion = Schema.decodeUnknownEffect(Schema.fromJsonString(UtilityCompletion))
// This issues its own /chat/completions call instead of steering core's title request.
// The `session.title` hook exposes `model` read-only, so pointing core at another model
// would mean widening that contract and re-resolving inside SessionModelRequest; rewriting
// the body in `http.request` is worse still, since the endpoint (/responses for GPT-5,
// /v1/messages for Claude) is fixed before that hook runs and gpt-4o-mini only serves
// /chat/completions. Utility titles are Copilot-specific, stateless, non-streaming, and
// free, so a small request here costs less than a core seam. Trade-offs: no session
// usage record for the title step (the call bills nothing) and no `http.*` hook visibility.
export function utilityTitle(
input: { baseURL: string; token: string; model: string; app: App.Info; fetch?: Fetch },
request: Pick<SessionTitle, "sessionID" | "system" | "messages" | "options">,
) {
const send = input.fetch ?? fetch
const text = (parts: ReadonlyArray<{ type: string; text?: string | null }>) =>
parts.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])).join("\n")
const messages = [
...(request.system.length ? [{ role: "system", content: text(request.system) }] : []),
...request.messages.map((message) => ({ role: message.role, content: text(message.content) })),
]
return Effect.tryPromise({
try: async (signal) => {
const response = await send(`${input.baseURL}/chat/completions`, {
method: "POST",
signal,
headers: {
Authorization: `Bearer ${input.token}`,
"Content-Type": "application/json",
"User-Agent": App.useragent(input.app),
"X-GitHub-Api-Version": apiVersion,
"Openai-Intent": "conversation-edits",
"X-Interaction-Type": "agent-session-name-generation",
"X-Interaction-Id": request.sessionID,
"x-initiator": "agent",
},
body: JSON.stringify({
model: input.model,
messages,
stream: false,
...(typeof request.options.maxTokens === "number" ? { max_tokens: request.options.maxTokens } : {}),
}),
})
if (!response.ok) throw new Error(`Utility title request failed: ${response.status}`)
return response.text()
},
catch: (cause) => cause,
}).pipe(
Effect.timeout("20 seconds"),
Effect.flatMap(decodeUtilityCompletion),
Effect.flatMap((completion) => {
const title = completion.choices[0]?.message.content?.trim()
return title ? Effect.succeed(title) : Effect.fail(new Error("Utility title response was empty"))
}),
)
}
export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, app: App.Info): Fetch {
const send = upstream ?? fetch
return async (input, init) => {
const requestHeaders = new Headers(init?.headers)
const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url
const body = typeof init?.body === "string" ? Option.getOrUndefined(decodeBody(init.body)) : undefined
applyHeaders(requestHeaders, token, app, requestMetadata(url, body), false)
return send(input, { ...init, headers: requestHeaders })
}
}
function applyHeaders(
headers: Headers,
token: string | undefined,
app: App.Info,
metadata: RequestMetadata,
anthropic: boolean,
) {
if (token) {
headers.delete("authorization")
headers.delete("x-api-key")
headers.set("Authorization", `Bearer ${token}`)
}
headers.set("User-Agent", App.useragent(app))
headers.set("Openai-Intent", "conversation-edits")
headers.set("X-GitHub-Api-Version", apiVersion)
// The step may already have declared itself agent-initiated (subagent, title, compaction);
// the body can only ever escalate to "agent", never back to "user".
if (metadata.agent) headers.set("x-initiator", "agent")
else if (!headers.has("x-initiator")) headers.set("x-initiator", "user")
if (metadata.vision) headers.set("Copilot-Vision-Request", "true")
if (anthropic) headers.set("anthropic-beta", "interleaved-thinking-2025-05-14")
}
// Mirrors the Copilot client's X-Interaction-Type vocabulary: the agent loop is the default,
// nested sessions are subagents, and title/compaction are the two utility overrides.
export function interactionType(kind: SessionRequestKind, child: boolean) {
if (kind === "title") return "conversation-background"
if (kind === "compaction") return "conversation-compaction"
if (child) return "conversation-subagent"
return "conversation-agent"
}
type RequestMetadata = ReturnType<typeof requestMetadata>
function requestMetadata(url: string, body: unknown) {
if (!record(body)) return { agent: false, vision: false }
if (Array.isArray(body.input)) {
const last = body.input.at(-1)
return {
agent: !record(last) || last.role !== "user",
vision: body.input.some(
(item) =>
record(item) &&
Array.isArray(item.content) &&
item.content.some((part) => record(part) && part.type === "input_image"),
),
}
}
if (!Array.isArray(body.messages)) return { agent: false, vision: false }
const last = body.messages.at(-1)
if (url.includes("completions")) {
return {
agent: !record(last) || last.role !== "user",
vision: body.messages.some(
(message) =>
record(message) &&
Array.isArray(message.content) &&
message.content.some((part) => record(part) && part.type === "image_url"),
),
}
}
const content = record(last) && Array.isArray(last.content) ? last.content : []
return {
agent:
!record(last) || last.role !== "user" || !content.some((part) => record(part) && part.type !== "tool_result"),
vision: body.messages.some(
(message) =>
record(message) &&
Array.isArray(message.content) &&
message.content.some(
(part) =>
record(part) &&
(part.type === "image" ||
(part.type === "tool_result" &&
Array.isArray(part.content) &&
part.content.some((nested) => record(nested) && nested.type === "image"))),
),
),
}
}
function record(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input)
}