浏览代码

fix(core): discover Copilot API endpoint (#38184)

Aiden Cline 3 周之前
父节点
当前提交
a0a6963beb

+ 46 - 11
packages/core/src/plugin/provider/github-copilot.ts

@@ -13,6 +13,7 @@ import type { PluginInternal } from "../internal"
 
 
 const clientID = "Ov23li8tweQw6odWQebz"
 const clientID = "Ov23li8tweQw6odWQebz"
 const apiVersion = "2026-06-01"
 const apiVersion = "2026-06-01"
+const userApiVersion = "2025-04-01"
 const pollingSafetyMargin = 3000
 const pollingSafetyMargin = 3000
 const methodID = Integration.MethodID.make("device")
 const methodID = Integration.MethodID.make("device")
 
 
@@ -27,6 +28,14 @@ const Token = Schema.Struct({
   error: Schema.optional(Schema.String),
   error: Schema.optional(Schema.String),
   interval: Schema.optional(Schema.Number),
   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 JsonBody = Schema.UnknownFromJsonString
 const decodeBody = Schema.decodeUnknownOption(JsonBody)
 const decodeBody = Schema.decodeUnknownOption(JsonBody)
 
 
@@ -81,15 +90,35 @@ const oauth = (app: App.Info) => ({
           Effect.map(Schema.decodeUnknownSync(Token)),
           Effect.map(Schema.decodeUnknownSync(Token)),
           Effect.flatMap((token) => {
           Effect.flatMap((token) => {
             if (token.access_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")
             if (token.error === "authorization_pending")
@@ -141,8 +170,7 @@ export const GithubCopilotPlugin = define({
         return
         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 provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot)
       const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot)
       const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot)
       loaded.models = yield* Effect.tryPromise({
       loaded.models = yield* Effect.tryPromise({
@@ -262,6 +290,13 @@ function baseURL(enterprise?: string) {
   return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
   return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
 }
 }
 
 
+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) {
 function headers(app: App.Info) {
   return {
   return {
     Accept: "application/json",
     Accept: "application/json",

+ 11 - 2
packages/core/test/plugin/provider-github-copilot.test.ts

@@ -1,12 +1,12 @@
 import { AISDK } from "@opencode-ai/core/aisdk"
 import { AISDK } from "@opencode-ai/core/aisdk"
 import { App } from "@opencode-ai/core/app"
 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 { Effect } from "effect"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { PluginHost } from "@opencode-ai/core/plugin/host"
 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 { ProviderV2 } from "@opencode-ai/core/provider"
 import { Integration } from "@opencode-ai/core/integration"
 import { Integration } from "@opencode-ai/core/integration"
 import type { LanguageModelV3 } from "@ai-sdk/provider"
 import type { LanguageModelV3 } from "@ai-sdk/provider"
@@ -41,6 +41,15 @@ function fakeSelectorSdk(calls: string[]) {
 }
 }
 
 
 describe("GithubCopilotPlugin", () => {
 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", () =>
   it.effect("registers GitHub Copilot device OAuth", () =>
     Effect.gen(function* () {
     Effect.gen(function* () {
       yield* addPlugin()
       yield* addPlugin()