diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 9c42ebaca..d97fde601 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -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 diff --git a/packages/catalog/src/model-manager.ts b/packages/catalog/src/model-manager.ts index ab69a18ff..eccec451f 100644 --- a/packages/catalog/src/model-manager.ts +++ b/packages/catalog/src/model-manager.ts @@ -349,7 +349,15 @@ function fingerprintStatic( } function mergeDynamicModel(existingModel: Model, dynamicModel: Model): Model { - 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({ diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 1716e9bbc..cc70d0b47 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -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["input"] = supportsVision - ? ["text", "image"] - : (reference?.input ?? defaults.input); + const input: ModelSpec["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 diff --git a/packages/catalog/src/wire/github-copilot.ts b/packages/catalog/src/wire/github-copilot.ts index cb4178238..0cb935e7f 100644 --- a/packages/catalog/src/wire/github-copilot.ts +++ b/packages/catalog/src/wire/github-copilot.ts @@ -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; diff --git a/packages/catalog/test/github-copilot-model-limits.test.ts b/packages/catalog/test/github-copilot-model-limits.test.ts index 710f0c460..d68b49c3d 100644 --- a/packages/catalog/test/github-copilot-model-limits.test.ts +++ b/packages/catalog/test/github-copilot-model-limits.test.ts @@ -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 }); + } + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 03ec72a40..a2b2927c5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/session/snapcompact-inline.ts b/packages/coding-agent/src/session/snapcompact-inline.ts index 7345053c1..a6d6f988f 100644 --- a/packages/coding-agent/src/session/snapcompact-inline.ts +++ b/packages/coding-agent/src/session/snapcompact-inline.ts @@ -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; 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 { // 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); diff --git a/packages/coding-agent/test/snapcompact-inline.test.ts b/packages/coding-agent/test/snapcompact-inline.test.ts index c22508d8b..36321c91e 100644 --- a/packages/coding-agent/test/snapcompact-inline.test.ts +++ b/packages/coding-agent/test/snapcompact-inline.test.ts @@ -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 }),