Skip to content

Instantly share code, notes, and snippets.

@JosXa
Created February 27, 2026 01:40
Show Gist options
  • Select an option

  • Save JosXa/ab2224a6134917e0dbf31bd08ce92104 to your computer and use it in GitHub Desktop.

Select an option

Save JosXa/ab2224a6134917e0dbf31bd08ce92104 to your computer and use it in GitHub Desktop.
OpenCode plugin: GitHub Copilot with GHE enterprise auth, GPT-5.3 support, and models.dev integration
import type { Hooks, PluginInput } from "@opencode-ai/plugin";
// Use VS Code's OAuth client ID - it has more scope permissions required for /copilot_internal/v2/token
const CLIENT_ID = "01ab8ac9400c4e429b23";
const PROVIDER_ID = "github-copilot";
const PROVIDER_NAME = "GitHub Copilot";
// TODO: Set this to your GitHub Enterprise domain
const DEFAULT_ENTERPRISE_DOMAIN = "your-company.ghe.com";
// 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
// Copilot API token cache - this token is exchanged from OAuth token and has feature flags
interface CopilotApiToken {
token: string;
expiresAt: number; // Unix timestamp in seconds
}
let copilotApiTokenCache: CopilotApiToken | null = null;
// Exchange OAuth token for Copilot API token (required for newer models like gpt-5.3-codex)
async function getCopilotApiToken(
oauthToken: string,
enterpriseUrl?: string,
): Promise<string | null> {
// Check cache first
if (
copilotApiTokenCache &&
copilotApiTokenCache.expiresAt > Date.now() / 1000 + 60
) {
return copilotApiTokenCache.token;
}
const domain = enterpriseUrl
? normalizeDomain(enterpriseUrl)
: "github.com";
const tokenUrl = `https://api.${domain}/copilot_internal/v2/token`;
try {
const response = await fetch(tokenUrl, {
method: "GET",
headers: {
// VS Code uses "token" prefix, not "Bearer"
authorization: `token ${oauthToken}`,
"user-agent": "GitHubCopilotChat/0.37.5",
"x-github-api-version": "2025-04-01",
// Include experiment flags for gpt-5.3-codex
"vscode-abexpcontext":
"00h15499_gpt_53_codex:31464543;gpt_5_3_codex_f4456sfsd:31465102;use-responses-api:31390855;",
},
});
if (!response.ok) {
return null;
}
const data = (await response.json()) as {
token: string;
expires_at?: number;
};
if (data.token) {
// Parse expiry from token string (format: tid=...;exp=1771511394;...)
let expiresAt = data.expires_at || Date.now() / 1000 + 3600; // Default 1 hour
const expMatch = data.token.match(/exp=(\d+)/);
if (expMatch) {
expiresAt = parseInt(expMatch[1], 10);
}
copilotApiTokenCache = {
token: data.token,
expiresAt,
};
return data.token;
}
return null;
} catch (error) {
console.error(`[copilot-ghe] Token exchange error:`, error);
return null;
}
}
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`,
};
}
// Fetch models from models.dev and create our provider definition
async function getGitHubCopilotModels() {
try {
const response = await fetch("https://models.dev/api.json");
if (!response.ok) return null;
const data = (await response.json()) as Record<string, any>;
const githubCopilot = data["github-copilot"];
if (!githubCopilot?.models) return null;
// Clone models and update providerID
const models: Record<string, any> = {};
for (const [modelId, model] of Object.entries(
githubCopilot.models as Record<string, any>,
)) {
models[modelId] = {
...model,
providerID: PROVIDER_ID,
};
}
// Ensure gpt-5.3-codex is always available (may not be in models.dev yet)
if (!models["gpt-5.3-codex"]) {
models["gpt-5.3-codex"] = {
id: "gpt-5.3-codex",
providerID: PROVIDER_ID,
name: "GPT-5.3 Codex",
api: {
id: "gpt-5.3-codex",
url: "https://api.githubcopilot.com",
npm: "@ai-sdk/github-copilot",
},
capabilities: {
input: { text: true, image: true, audio: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 256000, output: 32768 },
family: "gpt-5.3",
release_date: "2025-05-01",
status: "preview",
};
}
return {
id: PROVIDER_ID,
name: PROVIDER_NAME,
api: githubCopilot.api,
env: githubCopilot.env,
npm: githubCopilot.npm,
models,
};
} catch {
return null;
}
}
export async function CopilotGheAuthPlugin(input: PluginInput): Promise<Hooks> {
const sdk = input.client;
return {
// Config hook runs early - inject our provider with models from github-copilot
async config(config: any) {
const providerDef = await getGitHubCopilotModels();
if (providerDef) {
config.provider ??= {};
// Merge with existing config if present, otherwise create new
const existing = config.provider[PROVIDER_ID] || {};
config.provider[PROVIDER_ID] = {
name: existing.name || providerDef.name,
api: existing.api || providerDef.api,
env: existing.env || providerDef.env,
npm: existing.npm || providerDef.npm,
// Merge models - user config takes precedence
models: {
...providerDef.models,
...(existing.models || {}),
},
// Preserve user's blacklist
blacklist: existing.blacklist,
};
}
},
auth: {
// Use unique provider name to avoid conflict with built-in CopilotAuthPlugin
// Users login via "Other" → "github-copilot" in opencode auth login
provider: PROVIDER_ID,
async loader(getAuth, provider) {
const info = await getAuth();
if (!info || info.type !== "oauth") return {};
const enterpriseUrl = info.enterpriseUrl;
const baseURL = enterpriseUrl
? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
: undefined;
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
};
// TODO: re-enable once messages api has higher rate limits
// TODO: move some of this hacky-ness to models.dev presets once we have better grasp of things here...
// const base = baseURL ?? model.api.url
// const claude = model.id.includes("claude")
// const url = (() => {
// if (!claude) return base
// if (base.endsWith("/v1")) return base
// if (base.endsWith("/")) return `${base}v1`
// return `${base}/v1`
// })()
// model.api.url = url
// model.api.npm = claude ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"
model.api.npm = "@ai-sdk/github-copilot";
}
}
return {
baseURL,
apiKey: "",
async fetch(request: RequestInfo | URL, init?: RequestInit) {
const info = await getAuth();
if (info.type !== "oauth") return fetch(request, init);
// Get Copilot API token (required for newer models like gpt-5.3-codex)
const copilotApiToken = await getCopilotApiToken(
info.refresh,
info.enterpriseUrl,
);
const url =
request instanceof URL ? request.href : request.toString();
const { isVision, isAgent } = (() => {
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",
};
}
// 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",
};
}
// 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),
};
}
} catch { }
return { isVision: false, isAgent: false };
})();
const headers: Record<string, string> = {
"x-initiator": isAgent ? "agent" : "user",
...(init?.headers as Record<string, string>),
"User-Agent": `GitHubCopilotChat/0.37.5`,
// Use Copilot API token if available (required for gpt-5.3-codex), fallback to OAuth token
Authorization: `Bearer ${copilotApiToken || info.refresh}`,
"Openai-Intent": isAgent ? "conversation-agent" : "conversation-edits",
// VS Code-style headers that may be required for newer models on GHE
"Editor-Version": "vscode/1.109.2",
"Editor-Plugin-Version": "copilot-chat/0.37.5",
"Copilot-Integration-Id": "vscode-chat",
"X-GitHub-Api-Version": "2025-10-01",
// VS Code experiment flags that enable gpt-5.3-codex
"vscode-abexpcontext":
"00h15499_gpt_53_codex:31464543;gpt_5_3_codex_f4456sfsd:31465102;use-responses-api:31390855;",
};
if (isVision) {
headers["Copilot-Vision-Request"] = "true";
}
delete headers["x-api-key"];
delete headers["authorization"];
// GHE workarounds
let body = init?.body;
if (typeof body === "string") {
try {
const parsed = JSON.parse(body);
if (parsed.messages) {
// 1. Strip copilot_cache_control from messages and content parts
for (const msg of parsed.messages) {
if (msg.providerOptions?.copilot) {
delete msg.providerOptions.copilot;
}
if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part?.providerOptions?.copilot) {
delete part.providerOptions.copilot;
}
}
}
}
// 2. GHE requires tools array when history has tool calls
// Add dummy _noop tool if tools is empty but messages have tool_calls
const hasToolCalls = parsed.messages.some(
(msg: any) => msg.tool_calls && msg.tool_calls.length > 0,
);
const hasTools = parsed.tools && parsed.tools.length > 0;
if (hasToolCalls && !hasTools) {
parsed.tools = [
{
type: "function",
function: {
name: "_noop",
description: "Placeholder tool for GHE compatibility",
parameters: { type: "object", properties: {} },
},
},
];
}
}
body = JSON.stringify(parsed);
} catch { }
}
return fetch(request, {
...init,
headers,
body,
});
},
};
},
methods: [
{
type: "oauth",
label: "Login with GitHub Copilot (Enterprise)",
prompts: [],
async authorize() {
const domain = DEFAULT_ENTERPRISE_DOMAIN;
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/1.1.53`,
},
body: JSON.stringify({
client_id: CLIENT_ID,
// Use same scopes as VS Code - required for /copilot_internal/v2/token endpoint
scope: "read:user repo user:email workflow",
}),
});
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/1.1.53`,
},
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,
};
// Always save with our provider ID and the enterprise URL
result.enterpriseUrl = domain;
return result;
}
if (data.error === "authorization_pending") {
await Bun.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 Bun.sleep(
newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS,
);
continue;
}
if (data.error) return { type: "failed" as const };
await Bun.sleep(
deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS,
);
continue;
}
},
};
},
},
],
},
"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 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";
},
};
}
@JosXa
Copy link
Author

JosXa commented Feb 27, 2026

To upgrade:

The plugin/<your-plugin-name>.ts file is our custom enterprise version of the official plugin at https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/opencode/src/plugin/copilot.ts.

We customize:
- The CLIENT_ID 
- We inline the iife
- We hardcode the current version of opencode so that we don't need the @/installation import
- We shorten the interactive form so that the teamviewer.ghe.com URL is hardcoded and the step in the wizard is not required
- GPT-5.3 loading
- Copilot auth scopes

Please fetch the latest version of the official plugin and merge our changes into it so that we have the latest state of the upstream plugin.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment