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:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user