From 14cc26e86dfbb68d54e8bab0f955940bcf61b130 Mon Sep 17 00:00:00 2001 From: muyiyr <940955635@qq.com> Date: Thu, 6 Aug 2026 04:15:51 +0800 Subject: [PATCH] fix(ai): restore Copilot models from account policy (#7672) * fix(ai): fall back to Copilot model policy fixes #7634 * fix(ai): scope Copilot policy fallback --- packages/ai/src/auth/oauth/github-copilot.ts | 30 ++-- packages/ai/test/github-copilot-oauth.test.ts | 155 ++++++++++++------ 2 files changed, 123 insertions(+), 62 deletions(-) diff --git a/packages/ai/src/auth/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts index 6261972d2..43167e25f 100644 --- a/packages/ai/src/auth/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -89,28 +89,27 @@ function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? (value as Record) : undefined; } -function isSelectableCopilotModel(item: Record): boolean { - const policy = asRecord(item.policy); - const capabilities = asRecord(item.capabilities); - const supports = asRecord(capabilities?.supports); - return item.model_picker_enabled === true && policy?.state !== "disabled" && supports?.tool_calls !== false; -} - -function parseAvailableCopilotModelIds(raw: unknown): string[] { +function parseAvailableCopilotModelIds(raw: unknown, allowPolicyFallback: boolean): string[] { const data = asRecord(raw)?.data; if (!Array.isArray(data)) { throw new Error("Invalid Copilot models response"); } - const ids: string[] = []; + const pickerIds: string[] = []; + const policyEnabledIds: string[] = []; for (const rawItem of data) { const item = asRecord(rawItem); const id = item?.id; - if (typeof id === "string" && item && isSelectableCopilotModel(item)) { - ids.push(id); - } + if (!item || typeof id !== "string") continue; + + const capabilities = asRecord(item.capabilities); + const supports = asRecord(capabilities?.supports); + if (supports?.tool_calls === false) continue; + const policy = asRecord(item.policy); + if (item.model_picker_enabled === true && policy?.state !== "disabled") pickerIds.push(id); + if (policy?.state === "enabled") policyEnabledIds.push(id); } - return ids; + return pickerIds.length > 0 || !allowPolicyFallback ? pickerIds : policyEnabledIds; } async function fetchAvailableGitHubCopilotModelIds( @@ -119,6 +118,9 @@ async function fetchAvailableGitHubCopilotModelIds( signal: AbortSignal, ): Promise { const baseUrl = getGitHubCopilotBaseUrl(copilotToken, enterpriseDomain); + // Some Individual accounts return false for every picker flag despite explicit enabled policies. + // Limit the fallback to that endpoint so other account types keep strict picker semantics. + const allowPolicyFallback = baseUrl === "https://api.individual.githubcopilot.com"; const raw = await fetchJson(`${baseUrl}/models`, { headers: { Accept: "application/json", @@ -128,7 +130,7 @@ async function fetchAvailableGitHubCopilotModelIds( }, signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), }); - return parseAvailableCopilotModelIds(raw); + return parseAvailableCopilotModelIds(raw, allowPolicyFallback); } async function fetchJson(url: string, init: RequestInit): Promise { diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index cac3964a3..38934347a 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -55,6 +55,44 @@ function loginGitHubCopilotForTest(options: { }); } +async function refreshGitHubCopilotModelsForTest( + data: readonly unknown[], + proxyHost: string = "proxy.individual.githubcopilot.com", +) { + const accessToken = `tid=test;exp=9999999999;proxy-ep=${proxyHost};`; + const modelsUrl = `https://${proxyHost.replace(/^proxy\./, "api.")}/models`; + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: accessToken, + expires_at: 9999999999, + }); + } + + if (url === modelsUrl) { + expect(init?.headers).toMatchObject({ + Authorization: `Bearer ${accessToken}`, + }); + return jsonResponse({ data }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + return githubCopilotOAuth.refresh( + { + type: "oauth", + access: "old-access-token", + refresh: "ghu_refresh_token", + expires: 0, + }, + neverAbortedSignal, + ); +} + describe("GitHub Copilot OAuth device flow", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -62,56 +100,25 @@ describe("GitHub Copilot OAuth device flow", () => { }); it("filters models to the authenticated account picker catalog", async () => { - const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { - const url = getUrl(input); - - if (url.includes("/copilot_internal/v2/token")) { - return jsonResponse({ - token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", - expires_at: 9999999999, - }); - } - - if (url === "https://api.individual.githubcopilot.com/models") { - expect(init?.headers).toMatchObject({ - Authorization: "Bearer tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", - }); - return jsonResponse({ - data: [ - { - id: "gpt-4.1", - model_picker_enabled: true, - capabilities: { supports: { tool_calls: true } }, - }, - { - id: "claude-opus-4.7", - model_picker_enabled: true, - policy: { state: "disabled" }, - capabilities: { supports: { tool_calls: true } }, - }, - { - id: "gpt-5.4-nano", - model_picker_enabled: false, - capabilities: { supports: { tool_calls: true } }, - }, - ], - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }); - - vi.stubGlobal("fetch", fetchMock); - - const credentials = await githubCopilotOAuth.refresh( + const credentials = await refreshGitHubCopilotModelsForTest([ { - type: "oauth", - access: "old-access-token", - refresh: "ghu_refresh_token", - expires: 0, + id: "gpt-4.1", + model_picker_enabled: true, + capabilities: { supports: { tool_calls: true } }, }, - neverAbortedSignal, - ); + { + id: "claude-opus-4.7", + model_picker_enabled: true, + policy: { state: "disabled" }, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "gpt-5.4-nano", + model_picker_enabled: false, + policy: { state: "enabled" }, + capabilities: { supports: { tool_calls: true } }, + }, + ]); expect(credentials.availableModelIds).toEqual(["gpt-4.1"]); const store = new InMemoryCredentialStore(); @@ -121,6 +128,58 @@ describe("GitHub Copilot OAuth device flow", () => { expect((await models.getAvailable("github-copilot")).map((model) => model.id)).toEqual(["gpt-4.1"]); }); + it("falls back to explicitly enabled policy models when the picker catalog is empty", async () => { + const credentials = await refreshGitHubCopilotModelsForTest([ + { + id: "gpt-4.1", + model_picker_enabled: false, + policy: { state: "enabled" }, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "claude-opus-4.7", + model_picker_enabled: false, + policy: { state: "disabled" }, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "gpt-5.4-nano", + model_picker_enabled: false, + capabilities: { supports: { tool_calls: true } }, + }, + { + id: "gpt-4o", + model_picker_enabled: false, + policy: { state: "enabled" }, + capabilities: { supports: { tool_calls: false } }, + }, + ]); + + expect(credentials.availableModelIds).toEqual(["gpt-4.1"]); + + const store = new InMemoryCredentialStore(); + await store.modify("github-copilot", async () => ({ ...credentials, type: "oauth" })); + const models = createModels({ credentials: store }); + models.setProvider(githubCopilotProvider()); + expect((await models.getAvailable("github-copilot")).map((model) => model.id)).toEqual(["gpt-4.1"]); + }); + + it("does not fall back to policy models for non-Individual accounts", async () => { + const credentials = await refreshGitHubCopilotModelsForTest( + [ + { + id: "gpt-4.1", + model_picker_enabled: false, + policy: { state: "enabled" }, + capabilities: { supports: { tool_calls: true } }, + }, + ], + "proxy.business.githubcopilot.com", + ); + + expect(credentials.availableModelIds).toEqual([]); + }); + it("reports device-code details through onDeviceCode", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));