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:
roboomp
2026-06-24 16:25:54 +00:00
parent c053afa087
commit 714051d795
8 changed files with 221 additions and 9 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed GitHub Copilot business and enterprise endpoints accepting image inputs that they reject with `400 vision is not supported`. The Copilot `/models` response advertises `capabilities.supports.vision = true` for Claude/GPT chat models on every host, but only the canonical personal endpoint (`https://api.githubcopilot.com`) actually serves them; `githubCopilotModelManagerOptions` now forces `input: ["text"]` whenever discovery resolves to a non-personal base URL, and `mergeDynamicModel` honours the dynamic value (instead of OR-upgrading) when the merged endpoint differs from the bundled reference. ([#3387](https://github.com/can1357/oh-my-pi/issues/3387))
## [16.1.14] - 2026-06-22
### Added
+9 -1
View File
@@ -349,7 +349,15 @@ function fingerprintStatic<TApi extends Api>(
}
function mergeDynamicModel<TApi extends Api>(existingModel: Model<TApi>, dynamicModel: Model<TApi>): Model<TApi> {
const supportsImage = existingModel.input.includes("image") || dynamicModel.input.includes("image");
// When discovery resolves the same model id to a different endpoint (e.g.
// a GitHub Copilot business/enterprise host), the bundled reference's
// capabilities are pinned to the canonical host and no longer apply —
// honour the dynamic value alone. Same-endpoint merges still OR-upgrade so
// a discovery that omits the capability flag doesn't drop bundled vision.
const endpointChanged = existingModel.baseUrl !== dynamicModel.baseUrl;
const supportsImage = endpointChanged
? dynamicModel.input.includes("image")
: existingModel.input.includes("image") || dynamicModel.input.includes("image");
// Re-build from spec stage: sparse compat comes from `compatConfig` (the
// verbatim override vocabulary), never the resolved `compat` record.
return buildModel({
@@ -10,7 +10,12 @@ import type { ModelManagerOptions } from "../model-manager";
import { getBundledModels } from "../models";
import type { Api, FetchImpl, Model, ModelSpec, Provider, ThinkingConfig } from "../types";
import { isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils";
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";
@@ -3113,10 +3118,16 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
? entry.name
: (reference?.name ?? defaults.name);
const api = inferCopilotApi(defaults.id);
// `supports.vision` reports the model's intrinsic capability, but
// the business/enterprise endpoints respond `400 vision is not
// supported` on image inputs. Only honour the flag for the
// canonical personal-Copilot host.
const supportsVision = extractCopilotSupportsVision(entry);
const input: ModelSpec<Api>["input"] = supportsVision
? ["text", "image"]
: (reference?.input ?? defaults.input);
const input: ModelSpec<Api>["input"] = isPersonalGitHubCopilotBaseUrl(baseUrl)
? supportsVision
? ["text", "image"]
: (reference?.input ?? defaults.input)
: ["text"];
// 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
@@ -45,6 +45,20 @@ export function isPublicGitHubHost(host: string): boolean {
return PUBLIC_GITHUB_HOSTS.has(host.trim().toLowerCase());
}
/**
* Canonical personal-Copilot API host. The business
* (`api.business.githubcopilot.com`) and enterprise (`copilot-api.{domain}`)
* endpoints respond with HTTP 400 "vision is not supported" on image inputs,
* so catalog discovery and capability gates MUST honour the upstream's
* `supports.vision` flag only for this exact base URL.
*/
export const PERSONAL_GITHUB_COPILOT_BASE_URL = "https://api.githubcopilot.com" as const;
/** `true` when the resolved base URL is the canonical personal-Copilot host. */
export function isPersonalGitHubCopilotBaseUrl(baseUrl: string | undefined): boolean {
return baseUrl === PERSONAL_GITHUB_COPILOT_BASE_URL;
}
export function normalizeGitHubCopilotEnterpriseDomain(input: string | undefined): string | undefined {
const trimmed = input?.trim();
if (!trimmed) return undefined;
@@ -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 });
}
});
});
+1
View File
@@ -8,6 +8,7 @@
- Fixed llama.cpp discovery to prefer per-model `/v1/models` `meta.n_ctx`/`meta.n_ctx_train` values, refresh selected models after lazy load, and bypass fresh-cache reuse so server restarts update context windows. ([#3310](https://github.com/can1357/oh-my-pi/issues/3310))
- Fixed `task.maxConcurrency: 0` serializing subagent spawns instead of running them unbounded. The settings UI labels `0` as "Unlimited", but the session-scoped spawn `Semaphore` clamped `max` via `Math.max(1, max)`, so the second subagent body in a batch always waited for the first to release the seat. The constructor now treats `max <= 0` (and any non-finite input) as unbounded via `Number.POSITIVE_INFINITY`, matching the eval `parallel()`/`pipeline()` worker-pool semantics ([#3305](https://github.com/can1357/oh-my-pi/issues/3305)).
- Fixed MCP tool calls forwarding empty optional placeholder arguments (`""` and `{}`) to `tools/call`; optional placeholders are now omitted while required fields and meaningful falsy values are preserved. ([#3302](https://github.com/can1357/oh-my-pi/issues/3302))
- Fixed snapcompact rasterizing transcript frames into requests bound for GitHub Copilot business and enterprise endpoints, which then rejected the session permanently with `400 vision is not supported`. The snapcompact vision gate now also short-circuits whenever `model.provider === "github-copilot"` and the resolved `baseUrl` is not the canonical personal-Copilot host, protecting cached/stale Model specs that still advertise `["text","image"]` on a non-personal endpoint. ([#3387](https://github.com/can1357/oh-my-pi/issues/3387))
## [16.1.16] - 2026-06-23
@@ -16,6 +16,7 @@
import { countTokens } from "@oh-my-pi/pi-agent-core";
import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai";
import { isPersonalGitHubCopilotBaseUrl } from "@oh-my-pi/pi-catalog/wire/github-copilot";
import * as snapcompact from "@oh-my-pi/snapcompact";
import contextFramesNote from "../prompts/system/snapcompact-context-frames-note.md" with { type: "text" };
import contextStub from "../prompts/system/snapcompact-context-stub.md" with { type: "text" };
@@ -77,6 +78,19 @@ function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens:
return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN;
}
/**
* The model is vision-capable for the endpoint we're actually about to hit.
* GitHub Copilot business and enterprise hosts respond `400 vision is not
* supported` on image inputs (issue #3387), so even if a stale cached spec
* still advertises `["text","image"]` we MUST not rasterize transcripts when
* the resolved `baseUrl` is non-personal.
*/
function canSendImages(model: Model): boolean {
if (!model.input.includes("image")) return false;
if (model.provider === "github-copilot" && !isPersonalGitHubCopilotBaseUrl(model.baseUrl)) return false;
return true;
}
interface SystemPromptImageTarget {
scope: Exclude<SnapcompactSystemPromptMode, "none">;
text: string;
@@ -277,7 +291,7 @@ export function estimateInlineSavings(input: {
messages: readonly InlineMessageView[];
}): SnapcompactSavingsEstimate {
const { options, model } = input;
if (!model?.input.includes("image")) {
if (!model || !canSendImages(model)) {
return { visionCapable: false, savedTokens: 0 };
}
@@ -416,8 +430,10 @@ export class SnapcompactInlineTransformer {
async transform(context: Context, model: Model): Promise<Context> {
// Vision gate: providers silently DROP images on text-only models —
// rendering would lose the content entirely.
if (!model.input.includes("image")) return context;
// rendering would lose the content entirely. Also short-circuits when the
// resolved endpoint rejects vision regardless of the model's input list
// (issue #3387: Copilot business endpoint).
if (!canSendImages(model)) return context;
const shape = snapcompact.resolveShape(model, this.options.shape);
const budget = snapcompact.providerImageBudget(model.provider) - countContextImages(context);
@@ -54,6 +54,7 @@ function makeModel(
provider?: string;
input?: ("text" | "image")[];
api?: "anthropic-messages" | "google-generative-ai";
baseUrl?: string;
} = {},
) {
return buildModel({
@@ -61,7 +62,7 @@ function makeModel(
name: "Test Model",
api: overrides.api ?? "anthropic-messages",
provider: overrides.provider ?? "anthropic",
baseUrl: "https://example.invalid",
baseUrl: overrides.baseUrl ?? "https://example.invalid",
reasoning: false,
input: overrides.input ?? ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -100,6 +101,43 @@ describe("SnapcompactInlineTransformer", () => {
expect(await transformer.transform(context, makeModel({ input: ["text"] }))).toBe(context);
});
it("is a no-op for Copilot business/enterprise endpoints even when the model claims vision (#3387)", async () => {
const transformer = new SnapcompactInlineTransformer(
withTestShape({ renderSystemPrompt: "all", renderToolResults: true }),
);
const context = makeContext();
const business = makeModel({
provider: "github-copilot",
baseUrl: "https://api.business.githubcopilot.com",
input: ["text", "image"],
});
expect(await transformer.transform(context, business)).toBe(context);
expect(
estimateInlineSavings({
options: withTestShape({ renderSystemPrompt: "all", renderToolResults: true }),
model: business,
systemPrompt: context.systemPrompt ?? [],
messages: context.messages,
}),
).toEqual({ visionCapable: false, savedTokens: 0 });
const enterprise = makeModel({
provider: "github-copilot",
baseUrl: "https://copilot-api.ghe.example.com",
input: ["text", "image"],
});
expect(await transformer.transform(context, enterprise)).toBe(context);
const personal = makeModel({
provider: "github-copilot",
baseUrl: "https://api.githubcopilot.com",
input: ["text", "image"],
});
const result = await transformer.transform(context, personal);
expect(result).not.toBe(context);
expect(imageCount(result)).toBeGreaterThan(0);
});
it("images large historical tool results, keeping small and most-recent ones as text", async () => {
const transformer = new SnapcompactInlineTransformer(
withTestShape({ renderSystemPrompt: "none", renderToolResults: true }),