From 5e3e1372e3b89850a1d113923c555a08dff30806 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 16:58:52 -0500 Subject: [PATCH] fix(opencode): discover Copilot API endpoint --- .../src/plugin/github-copilot/copilot.ts | 34 ++++++++--- .../test/plugin/github-copilot-models.test.ts | 57 +++++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index 9c744db89b..739f8ce445 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -8,6 +8,7 @@ import { MessageV2 } from "@/session/message-v2" const CLIENT_ID = "Ov23li8tweQw6odWQebz" const API_VERSION = "2026-06-01" +const USER_API_VERSION = "2025-04-01" const UTILITY_MODELS = ["gpt-5.4-nano", "gpt-4.1", "gpt-4o", "gpt-4o-mini"] // Add a small safety buffer when polling to avoid hitting the server // slightly too early due to clock skew / timer drift. @@ -66,9 +67,28 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { } const auth = ctx.auth + const fallback = base(auth.enterpriseUrl) + const url = await fetch( + `${auth.enterpriseUrl ? `https://api.${normalizeDomain(auth.enterpriseUrl)}` : "https://api.github.com"}/copilot_internal/user`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${auth.refresh}`, + "X-GitHub-Api-Version": USER_API_VERSION, + }, + signal: AbortSignal.timeout(5_000), + }, + ) + .then(async (response) => { + if (!response.ok) return fallback + const result = (await response.json()) as { endpoints?: { api?: unknown } } + const endpoint = result.endpoints?.api + return typeof endpoint === "string" && endpoint ? endpoint.replace(/\/+$/, "") : fallback + }) + .catch(() => fallback) return CopilotModels.get( - base(auth.enterpriseUrl), + url, { ...(provider.options?.headers as Record | undefined), Authorization: `Bearer ${auth.refresh}`, @@ -85,9 +105,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { }) .catch((error) => { models = {} - return Object.fromEntries( - Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]), - ) + return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model, url)])) }) }, }, @@ -187,17 +205,17 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { { type: "select", key: "deploymentType", - message: "Select GitHub deployment type", + message: "Select where your GitHub account is hosted", options: [ { label: "GitHub.com", value: "github.com", - hint: "Public", + hint: "Including Copilot Business or Enterprise", }, { - label: "GitHub Enterprise", + label: "GitHub Enterprise host", value: "enterprise", - hint: "Data residency or self-hosted", + hint: "GHE.com or self-hosted", }, ], }, diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index e6d89fe01c..ddd6741f95 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -422,3 +422,60 @@ test("remaps fallback oauth model urls to the enterprise host", async () => { expect(models.claude.api.url).toBe("https://copilot-api.ghe.example.com") expect(models.claude.api.npm).toBe("@ai-sdk/github-copilot") }) + +test("uses the account-specific Copilot API endpoint", async () => { + const requests: string[] = [] + globalThis.fetch = mock((input: RequestInfo | URL) => { + const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url + requests.push(url) + if (url === "https://api.github.com/copilot_internal/user") { + return Promise.resolve( + new Response(JSON.stringify({ endpoints: { api: "https://api.business.githubcopilot.com/" } })), + ) + } + return Promise.reject(new Error("timeout")) + }) as unknown as typeof fetch + + const hooks = await CopilotAuthPlugin({ + client: {} as never, + project: {} as never, + directory: "", + worktree: "", + experimental_workspace: { + register() {}, + }, + serverUrl: new URL("https://example.com"), + $: {} as never, + }) + + const models = await hooks.provider!.models!( + { + id: "github-copilot", + models: { + claude: { + id: "claude", + providerID: "github-copilot", + api: { + id: "claude-sonnet-4.5", + url: "https://api.githubcopilot.com/v1", + npm: "@ai-sdk/anthropic", + }, + }, + }, + } as never, + { + auth: { + type: "oauth", + refresh: "token", + access: "token", + expires: Date.now() + 60_000, + } as never, + }, + ) + + expect(requests).toEqual([ + "https://api.github.com/copilot_internal/user", + "https://api.business.githubcopilot.com/models", + ]) + expect(models.claude.api.url).toBe("https://api.business.githubcopilot.com") +})