fix(providers): restored copilot business vision

Honor GitHub Copilot /models vision support on Business and Enterprise endpoints and remove the stale snapcompact non-personal-host block.

Fixes #4779
This commit is contained in:
roboomp
2026-07-10 11:51:54 +00:00
parent ac2ea80fa3
commit b0f22caf83
7 changed files with 84 additions and 85 deletions
@@ -526,10 +526,7 @@ describe("github copilot vision endpoint policy", () => {
enterpriseUrl: "ghe.example.com",
});
it("strips vision when discovery resolves to the business endpoint, even though upstream reports it", async () => {
// `api.business.githubcopilot.com` responds `400 vision is not supported`
// on image inputs (issue #3387), so the catalog MUST ignore the upstream's
// `supports.vision = true` flag for non-personal hosts.
it("keeps vision when discovery resolves to the business endpoint and upstream reports it", async () => {
const { models } = await discoverCopilotModels(
{
data: [
@@ -548,10 +545,10 @@ describe("github copilot vision endpoint policy", () => {
);
const model = models.find(candidate => candidate.id === "claude-sonnet-4.6");
expect(model?.baseUrl).toBe("https://api.business.githubcopilot.com");
expect(model?.input).toEqual(["text"]);
expect(model?.input).toEqual(["text", "image"]);
});
it("strips vision when discovery resolves to an enterprise host", async () => {
it("keeps vision when discovery resolves to an enterprise host and upstream reports it", async () => {
const { models } = await discoverCopilotModels(
{
data: [
@@ -570,7 +567,42 @@ describe("github copilot vision endpoint policy", () => {
);
const model = models.find(candidate => candidate.id === "claude-sonnet-4.6");
expect(model?.baseUrl).toBe("https://copilot-api.ghe.example.com");
expect(model?.input).toEqual(["text"]);
expect(model?.input).toEqual(["text", "image"]);
});
it("maps explicit upstream vision false to text-only on non-personal Copilot endpoints", async () => {
for (const endpoint of [
{
apiKey: businessApiKey,
baseUrl: "https://api.business.githubcopilot.com",
token: "ghu_business_token",
},
{
apiKey: enterpriseApiKey,
baseUrl: "https://copilot-api.ghe.example.com",
token: "ghu_enterprise_token",
},
]) {
const { models } = await discoverCopilotModels(
{
data: [
tieredCopilotEntry({
id: "claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
window: 200_000,
maxOutput: 32_000,
vision: false,
}),
],
},
endpoint.apiKey,
endpoint.baseUrl,
endpoint.token,
);
const model = models.find(candidate => candidate.id === "claude-sonnet-4.6");
expect(model?.baseUrl).toBe(endpoint.baseUrl);
expect(model?.input).toEqual(["text"]);
}
});
it("keeps vision on the canonical personal Copilot endpoint", async () => {
@@ -590,11 +622,11 @@ describe("github copilot vision endpoint policy", () => {
expect(model?.input).toEqual(["text", "image"]);
});
it("downgrades the merged Model to text-only when business discovery overrides a vision-capable bundled reference", async () => {
// Bundled `claude-sonnet-4.6` ships with `input=['text','image']` and the
// canonical baseUrl. Discovery against the business host hands back a
// dynamic entry with the business baseUrl; the merge MUST honour the
// dynamic side's text-only capability instead of OR-upgrading.
it("keeps the merged Model image-capable when business discovery confirms a vision-capable bundled reference", async () => {
// Bundled `claude-sonnet-4.6` ships with `input=['text','image']`.
// Discovery against the business host confirms the same upstream vision
// capability; the full manager merge must preserve image input instead
// of downgrading solely because the baseUrl is non-personal.
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-copilot-vision-"));
try {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
@@ -629,7 +661,7 @@ describe("github copilot vision endpoint policy", () => {
const { models } = await manager.refresh("online");
const model = models.find(candidate => candidate.id === "claude-sonnet-4.6");
expect(model?.baseUrl).toBe("https://api.business.githubcopilot.com");
expect(model?.input).toEqual(["text"]);
expect(model?.input).toEqual(["text", "image"]);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}