mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
04d6447f7c
commit
14cc26e86d
2 changed files with 123 additions and 62 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue