diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 71de0b7b6b..94414eda3d 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -13,6 +13,7 @@ import type { PluginInternal } from "../internal" const clientID = "Ov23li8tweQw6odWQebz" const apiVersion = "2026-06-01" +const userApiVersion = "2025-04-01" const pollingSafetyMargin = 3000 const methodID = Integration.MethodID.make("device") @@ -27,6 +28,14 @@ const Token = Schema.Struct({ error: Schema.optional(Schema.String), interval: Schema.optional(Schema.Number), }) +const User = Schema.Struct({ + endpoints: Schema.optional( + Schema.Struct({ + api: Schema.optional(Schema.String), + }), + ), +}) +const decodeUser = Schema.decodeUnknownOption(User) const JsonBody = Schema.UnknownFromJsonString const decodeBody = Schema.decodeUnknownOption(JsonBody) @@ -81,15 +90,35 @@ const oauth = (app: App.Info) => ({ Effect.map(Schema.decodeUnknownSync(Token)), Effect.flatMap((token) => { if (token.access_token) { - return Effect.succeed( - Credential.OAuth.make({ - type: "oauth", - methodID, - refresh: token.access_token, - access: token.access_token, - expires: 0, - ...(enterprise ? { metadata: { enterpriseUrl: domain } } : {}), - }), + 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))?.endpoints?.api?.replace(/\/+$/, "")), + Effect.catch(() => Effect.succeed(undefined)), + Effect.map((apiEndpoint) => + Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: access, + access, + expires: 0, + ...((enterprise || apiEndpoint) && { + metadata: { + ...(enterprise ? { enterpriseUrl: domain } : {}), + ...(apiEndpoint ? { apiEndpoint } : {}), + }, + }), + }), + ), ) } if (token.error === "authorization_pending") @@ -141,8 +170,7 @@ export const GithubCopilotPlugin = define({ return } - const enterprise = credential.metadata?.enterpriseUrl - loaded.baseURL = baseURL(typeof enterprise === "string" ? enterprise : undefined) + loaded.baseURL = copilotBaseURL(credential.metadata) const provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot) const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot) loaded.models = yield* Effect.tryPromise({ @@ -262,6 +290,13 @@ function baseURL(enterprise?: string) { return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com" } +export function copilotBaseURL(metadata?: Readonly>) { + 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", diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 328007f2da..bb2443ae41 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -1,12 +1,12 @@ import { AISDK } from "@opencode-ai/core/aisdk" import { App } from "@opencode-ai/core/app" -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" -import { copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" +import { copilotBaseURL, copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { ProviderV2 } from "@opencode-ai/core/provider" import { Integration } from "@opencode-ai/core/integration" import type { LanguageModelV3 } from "@ai-sdk/provider" @@ -41,6 +41,15 @@ function fakeSelectorSdk(calls: string[]) { } describe("GithubCopilotPlugin", () => { + test("prefers the account-specific Copilot API endpoint", () => { + expect( + copilotBaseURL({ + enterpriseUrl: "company.ghe.com", + apiEndpoint: "https://api.business.githubcopilot.com", + }), + ).toBe("https://api.business.githubcopilot.com") + }) + it.effect("registers GitHub Copilot device OAuth", () => Effect.gen(function* () { yield* addPlugin()