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
This commit is contained in:
muyiyr 2026-08-06 04:15:51 +08:00 • committed by GitHub
parent 04d6447f7c
commit 14cc26e86d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 123 additions and 62 deletions

View file

@ -89,28 +89,27 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
function isSelectableCopilotModel(item: Record<string, unknown>): 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<string[]> {
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<unknown> {

View file

@ -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<Response> => {
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<Response> => {
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"));