Created
February 27, 2026 01:40
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | |
| }, | |
| }; | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
To upgrade: