fix(catalog,coding-agent): disable vision on non-personal copilot endpoints
GitHub Copilot's /models response advertises supports.vision = true for Claude/GPT chat models on every host, but only the canonical personal endpoint (https://api.githubcopilot.com) actually accepts image inputs; the business (api.business.githubcopilot.com) and enterprise (copilot-api.{domain}) hosts respond '400 vision is not supported'. snapcompact then injected rasterized transcript frames after compaction and permanently broke every business-Copilot session. - Catalog discovery (githubCopilotModelManagerOptions.mapModel) now forces input=['text'] whenever the resolved baseUrl is not the canonical personal-Copilot host, so the upstream's vision flag is honoured only where it actually works. - mergeDynamicModel honours the dynamic input value (instead of OR-upgrading with the bundled reference) when the merged baseUrl differs from the bundled one, so a bundled spec pinned to the personal host can no longer taint a business-resolved merge. - snapcompact-inline's canSendImages helper short-circuits the rasterizer for any github-copilot model whose baseUrl is non-personal, catching stale cached specs that still advertise vision. - Helper isPersonalGitHubCopilotBaseUrl exported from pi-catalog/wire/github-copilot so catalog and coding-agent share one canonical check. Regression coverage in github-copilot-model-limits.test.ts (vision endpoint policy + full merge) and snapcompact-inline.test.ts (#3387 business/enterprise case). Fixes #3387
This commit is contained in:
@@ -515,3 +515,123 @@ describe("github copilot tiered context windows", () => {
|
||||
expect(served[0]?.requestModelId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("github copilot vision endpoint policy", () => {
|
||||
const businessApiKey = JSON.stringify({
|
||||
token: "ghu_business_token",
|
||||
apiEndpoint: "https://api.business.githubcopilot.com",
|
||||
});
|
||||
const enterpriseApiKey = JSON.stringify({
|
||||
token: "ghu_enterprise_token",
|
||||
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.
|
||||
const { models } = await discoverCopilotModels(
|
||||
{
|
||||
data: [
|
||||
tieredCopilotEntry({
|
||||
id: "claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
window: 200_000,
|
||||
maxOutput: 32_000,
|
||||
vision: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
businessApiKey,
|
||||
"https://api.business.githubcopilot.com",
|
||||
"ghu_business_token",
|
||||
);
|
||||
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"]);
|
||||
});
|
||||
|
||||
it("strips vision when discovery resolves to an enterprise host", async () => {
|
||||
const { models } = await discoverCopilotModels(
|
||||
{
|
||||
data: [
|
||||
tieredCopilotEntry({
|
||||
id: "claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
window: 200_000,
|
||||
maxOutput: 32_000,
|
||||
vision: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
enterpriseApiKey,
|
||||
"https://copilot-api.ghe.example.com",
|
||||
"ghu_enterprise_token",
|
||||
);
|
||||
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"]);
|
||||
});
|
||||
|
||||
it("keeps vision on the canonical personal Copilot endpoint", async () => {
|
||||
const { models } = await discoverCopilotModels({
|
||||
data: [
|
||||
tieredCopilotEntry({
|
||||
id: "claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
window: 200_000,
|
||||
maxOutput: 32_000,
|
||||
vision: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const model = models.find(candidate => candidate.id === "claude-sonnet-4.6");
|
||||
expect(model?.baseUrl).toBe("https://api.githubcopilot.com");
|
||||
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.
|
||||
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) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
expect(url).toBe("https://api.business.githubcopilot.com/models");
|
||||
expect(getHeaderValue(init?.headers, "Authorization")).toBe("Bearer ghu_business_token");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
tieredCopilotEntry({
|
||||
id: "claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
window: 200_000,
|
||||
maxOutput: 32_000,
|
||||
vision: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
const bundled = getBundledModel("github-copilot", "claude-sonnet-4.6");
|
||||
expect(bundled?.input).toEqual(["text", "image"]);
|
||||
expect(bundled?.baseUrl).toBe("https://api.githubcopilot.com");
|
||||
|
||||
const options = githubCopilotModelManagerOptions({ apiKey: businessApiKey, fetch: fetchMock });
|
||||
const manager = createModelManager({
|
||||
...options,
|
||||
cacheDbPath: path.join(tempDir, "models.db"),
|
||||
});
|
||||
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"]);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user