From 4bae9a42ab764336ab17fdf321fe8d23e8ed8fcd Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 10 Jul 2026 12:08:43 +0000 Subject: [PATCH] fix(providers): required copilot vision confirmation Keep non-personal Copilot endpoints text-only when discovery omits supports.vision, while preserving explicit vision support. Fixes #4779 --- .../src/provider-models/openai-compat.ts | 17 ++++++---- .../test/github-copilot-model-limits.test.ts | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index b4a7ba007..2f52dbe6f 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -16,7 +16,12 @@ import { getBundledModels } from "../models"; import type { Api, FetchImpl, Model, ModelSpec, OpenAICompat, Provider, ThinkingConfig } from "../types"; import { discoveryFetch, isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils"; import { coreWeaveProjectHeaders } from "../wire/coreweave"; -import { COPILOT_API_HEADERS, getGitHubCopilotBaseUrl, parseGitHubCopilotApiKey } from "../wire/github-copilot"; +import { + COPILOT_API_HEADERS, + getGitHubCopilotBaseUrl, + isPersonalGitHubCopilotBaseUrl, + parseGitHubCopilotApiKey, +} from "../wire/github-copilot"; import { createBundledReferenceMap, createReferenceResolver, toModelSpec } from "./bundled-references"; const MODELS_DEV_URL = "https://models.dev/api.json"; @@ -3692,11 +3697,11 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana const api = inferCopilotApi(defaults.id); const supportsVision = extractCopilotSupportsVision(entry); const input: ModelSpec["input"] = - supportsVision === undefined - ? (reference?.input ?? defaults.input) - : supportsVision - ? ["text", "image"] - : ["text"]; + supportsVision === true + ? ["text", "image"] + : supportsVision === false || !isPersonalGitHubCopilotBaseUrl(baseUrl) + ? ["text"] + : (reference?.input ?? defaults.input); // With COPILOT_API_HEADERS the served window is the long-context // ceiling; the default tier ends at token_prices.default.context_max // prompt tokens. Cap the base entry to the default tier — the long diff --git a/packages/catalog/test/github-copilot-model-limits.test.ts b/packages/catalog/test/github-copilot-model-limits.test.ts index b6ecfe3d4..aa5cc66e7 100644 --- a/packages/catalog/test/github-copilot-model-limits.test.ts +++ b/packages/catalog/test/github-copilot-model-limits.test.ts @@ -605,6 +605,40 @@ describe("github copilot vision endpoint policy", () => { } }); + it("maps omitted upstream vision 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, + }), + ], + }, + 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 () => { const { models } = await discoverCopilotModels({ data: [