diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7f34ea30e..78d7dce64 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -67,6 +67,7 @@ - Fixed Antigravity and Gemini CLI model requests failing with an opaque error when Google requires account verification. Cloud Code Assist `403 VALIDATION_REQUIRED` responses now surface the `validation_url` and the signed-in account email when available, so users see an actionable account-verification message instead of the raw API error body. - Fixed MiniMax M3 in-band tool calls by adding a MiniMax dialect that parses `` wrappers instead of falling back to generic XML. ([#2759](https://github.com/can1357/oh-my-pi/issues/2759)) - Fixed GitHub Copilot OAuth for Business seats by storing the login-discovered API endpoint and routing model enablement plus chat requests to that endpoint. ([#2876](https://github.com/can1357/oh-my-pi/issues/2876)) +- Fixed GitHub Copilot Responses requests rejecting image inputs that carry the `detail: "original"` hint with an HTTP 400 by degrading the hint to `"auto"` for hosts that do not support it; other hosts still preserve native-resolution frames (snapcompact). ([#2822](https://github.com/can1357/oh-my-pi/issues/2822)) ## [16.0.4] - 2026-06-17 diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 14982d573..5202d1f9f 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -297,6 +297,7 @@ function buildParams( model, context, strictResponsesPairing: true, + supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal, systemRole, includeThinkingSignatures: true, developerStringContent: true, diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index cfc8fdcc1..4ac3ed632 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -3253,7 +3253,15 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex } if (msg.role === "toolResult") { - appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds); + appendResponsesToolResultMessages( + messages, + msg, + model, + false, + model.compat.supportsImageDetailOriginal, + knownCallIds, + customCallIds, + ); } msgIndex += 1; @@ -3271,7 +3279,10 @@ function normalizeInputMessageContent( return [{ type: "input_text", text: content.toWellFormed() }]; } - return convertResponsesInputContent(content, model.input.includes("image")) ?? []; + return ( + convertResponsesInputContent(content, model.input.includes("image"), model.compat.supportsImageDetailOriginal) ?? + [] + ); } /** @internal Exported for tests. */ diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 61aef61af..3ef9c82bf 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -687,6 +687,7 @@ export function buildParams( model, context, strictResponsesPairing, + supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal, nativeHistory: { replay: shouldReplayNativeHistory, filterReasoning: policy.reasoning.filterReasoningHistory, diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index 684c4a02b..405e7bffd 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -1178,9 +1178,24 @@ export function repairOrphanResponsesToolCalls(input: ResponseInput): ResponseIn return repaired; } +/** + * Some Responses backends (notably GitHub Copilot) reject the OpenAI image + * `detail: "original"` value with a 400. When the model does not advertise + * support for it, degrade `"original"` to `"auto"` so the request still goes + * through with the closest valid fidelity instead of failing outright. See #2822. + */ +function clampResponsesImageDetail( + detail: ImageContent["detail"], + supportsImageDetailOriginal: boolean, +): ResponseInputImage["detail"] { + const resolved = detail ?? "auto"; + return resolved === "original" && !supportsImageDetailOriginal ? "auto" : resolved; +} + export function convertResponsesInputContent( content: string | Array, supportsImages: boolean, + supportsImageDetailOriginal: boolean, ): ResponseInputContent[] | undefined { if (typeof content === "string") { if (content.trim().length === 0) return undefined; @@ -1200,7 +1215,7 @@ export function convertResponsesInputContent( for (const item of imageBlocks) { normalizedContent.push({ type: "input_image", - detail: item.detail ?? "auto", + detail: clampResponsesImageDetail(item.detail, supportsImageDetailOriginal), image_url: `data:${item.mimeType};base64,${item.data}`, } satisfies ResponseInputImage); } @@ -1217,6 +1232,7 @@ export interface BuildResponsesInputOptions { model: Model; context: Context; strictResponsesPairing: boolean; + supportsImageDetailOriginal: boolean; systemRole?: "system" | "developer"; nativeHistory?: { replay: boolean; @@ -1267,7 +1283,11 @@ export function buildResponsesInput(options: BuildResponsesInp msgIndex++; continue; } - const content = convertResponsesInputContent(msg.content, options.model.input.includes("image")); + const content = convertResponsesInputContent( + msg.content, + options.model.input.includes("image"), + options.supportsImageDetailOriginal, + ); if (!content) continue; messages.push({ role: "user", @@ -1318,6 +1338,7 @@ export function buildResponsesInput(options: BuildResponsesInp msg, options.model, options.strictResponsesPairing, + options.supportsImageDetailOriginal, knownCallIds, customCallIds, ); @@ -1419,6 +1440,7 @@ export function appendResponsesToolResultMessages( toolResult: ToolResultMessage, model: Model, strictResponsesPairing: boolean, + supportsImageDetailOriginal: boolean, knownCallIds: ReadonlySet, customCallIds?: ReadonlySet, ): void { @@ -1475,7 +1497,7 @@ export function appendResponsesToolResultMessages( if (block.type === "image") { contentParts.push({ type: "input_image", - detail: block.detail ?? "auto", + detail: clampResponsesImageDetail(block.detail, supportsImageDetailOriginal), image_url: `data:${block.mimeType};base64,${block.data}`, } satisfies ResponseInputImage); } diff --git a/packages/ai/test/apply-patch-freeform.test.ts b/packages/ai/test/apply-patch-freeform.test.ts index a0ac5afdf..7e539ff9c 100644 --- a/packages/ai/test/apply-patch-freeform.test.ts +++ b/packages/ai/test/apply-patch-freeform.test.ts @@ -659,8 +659,17 @@ describe("history replay: custom_tool_call round-trip", () => { }; const knownCallIds = new Set(["call_1"]); const customCallIds = new Set(["call_1"]); + const model = makeModel(); - appendResponsesToolResultMessages(messages as never, toolResult, makeModel(), true, knownCallIds, customCallIds); + appendResponsesToolResultMessages( + messages as never, + toolResult, + model, + true, + model.compat.supportsImageDetailOriginal, + knownCallIds, + customCallIds, + ); expect(messages).toHaveLength(1); const item = messages[0] as { type: string; call_id: string; output: string }; @@ -681,8 +690,17 @@ describe("history replay: custom_tool_call round-trip", () => { }; const knownCallIds = new Set(["call_2"]); const customCallIds = new Set(); // call_2 not custom + const model = makeModel(); - appendResponsesToolResultMessages(messages as never, toolResult, makeModel(), true, knownCallIds, customCallIds); + appendResponsesToolResultMessages( + messages as never, + toolResult, + model, + true, + model.compat.supportsImageDetailOriginal, + knownCallIds, + customCallIds, + ); const item = messages[0] as { type: string }; expect(item.type).toBe("function_call_output"); diff --git a/packages/ai/test/github-copilot-long-context-wire.test.ts b/packages/ai/test/github-copilot-long-context-wire.test.ts index 86abab512..64de79a19 100644 --- a/packages/ai/test/github-copilot-long-context-wire.test.ts +++ b/packages/ai/test/github-copilot-long-context-wire.test.ts @@ -15,8 +15,12 @@ afterEach(() => { vi.restoreAllMocks(); }); +// Fixed timestamp keeps the wire-body assertions deterministic; the value is +// never read on the wire, but pinning it avoids any incidental nondeterminism. +const FIXED_TIMESTAMP = 1_700_000_000_000; + const testContext: Context = { - messages: [{ role: "user", content: "hello", timestamp: Date.now() }], + messages: [{ role: "user", content: "hello", timestamp: FIXED_TIMESTAMP }], }; function makeLongContextVariant(spec: Partial> & { api: TApi }): Model { @@ -112,3 +116,109 @@ describe("GitHub Copilot long-context variant wire model id", () => { expect(wireModelIds[0]).toBe("gemini-3.1-pro-preview"); }); }); + +/** + * GitHub Copilot's Responses endpoint rejects the `detail: "original"` image + * hint (an oh-my-pi extension that preserves native-resolution snapcompact + * frames) with an HTTP 400. The catalog resolves `supportsImageDetailOriginal` + * to `false` for Copilot, and the Responses request builder degrades the hint + * to `"auto"` so the wire stays valid. Every other host preserves `"original"`. + */ +describe("GitHub Copilot Responses image detail clamp (#2822)", () => { + const imageContext: Context = { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe this frame" }, + { type: "image", mimeType: "image/png", data: "ZmFrZQ==", detail: "original" }, + ], + timestamp: FIXED_TIMESTAMP, + }, + ], + }; + + // Walk a serialized Responses request body and return the first `input_image` + // detail hint it emits (the wire nests it under `input[].content[]`). + function firstImageDetail(body: Record): string | undefined { + let found: string | undefined; + const walk = (node: unknown): void => { + if (found !== undefined || node === null || typeof node !== "object") return; + if (Array.isArray(node)) { + for (const child of node) walk(child); + return; + } + const obj = node as Record; + if (obj.type === "input_image" && typeof obj.detail === "string") { + found = obj.detail; + return; + } + for (const value of Object.values(obj)) walk(value); + }; + walk(body); + return found; + } + + async function detailOnWire(model: Model<"openai-responses">): Promise { + let body: Record | undefined; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + body = await getRequestBody(input, init); + return createUnauthorizedResponse(); + }); + try { + // The mocked fetch returns 401; this test only cares about what was + // serialized onto the wire, which `fetchMock` captures into `body` + // before `.result()` settles. Tolerate the result rejecting. + await streamOpenAIResponses(model, imageContext, { + apiKey: "ghu_test_copilot_token", + fetch: fetchMock as unknown as typeof fetch, + }).result(); + } catch { + // Ignore: the 401 may surface as a rejection on some result paths. + } + return body === undefined ? undefined : firstImageDetail(body); + } + + it("degrades `original` to `auto` for GitHub Copilot, which rejects it with a 400", async () => { + const model = makeLongContextVariant({ + api: "openai-responses", + id: "gpt-5.5-1m", + requestModelId: "gpt-5.5", + name: "GPT-5.5 (1M)", + }); + expect(model.compat.supportsImageDetailOriginal).toBe(false); + expect(await detailOnWire(model)).toBe("auto"); + }); + + it("preserves `original` for non-Copilot Responses hosts (snapcompact native frames)", async () => { + const model = makeLongContextVariant({ + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + headers: {}, + id: "gpt-5.5", + requestModelId: "gpt-5.5", + name: "GPT-5.5", + }); + expect(model.compat.supportsImageDetailOriginal).toBe(true); + expect(await detailOnWire(model)).toBe("original"); + }); + + it("clamps `original` when only the base URL identifies Copilot (provider id differs)", async () => { + // A model pointed at the Copilot Responses host but labeled with a generic + // provider id must still degrade `original`. Detecting Copilot solely by the + // provider field would resolve `supportsImageDetailOriginal: true` here and + // reintroduce the HTTP 400 this clamp prevents; host-aware detection keeps it + // `false`. + const model = makeLongContextVariant({ + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.githubcopilot.com", + id: "gpt-5.5-1m", + requestModelId: "gpt-5.5", + name: "GPT-5.5 (1M)", + }); + expect(model.compat.supportsImageDetailOriginal).toBe(false); + expect(await detailOnWire(model)).toBe("auto"); + }); +}); diff --git a/packages/ai/test/issue-967-vision-guard.test.ts b/packages/ai/test/issue-967-vision-guard.test.ts index dd1c572dc..fc1c6ffed 100644 --- a/packages/ai/test/issue-967-vision-guard.test.ts +++ b/packages/ai/test/issue-967-vision-guard.test.ts @@ -171,6 +171,7 @@ describe("issue #967 vision guard", () => { { type: "image", mimeType: "image/png", data: "ZmFrZQ==" }, ], false, + model.compat.supportsImageDetailOriginal, ); expect(countTaggedValues(userContent, "input_image")).toBe(0); expect(userContent).toEqual([ @@ -187,6 +188,7 @@ describe("issue #967 vision guard", () => { ]), model, true, + model.compat.supportsImageDetailOriginal, new Set(["call_1"]), ); expect(countTaggedValues(payload, "input_image")).toBe(0); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 8644bbdee..f1d1c9c5b 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -45,6 +45,7 @@ - Routed google-antigravity default baseUrl to the stable primary daily endpoint in the catalog generator and all fallback snapshots, resolving connection drops on heavy queries. - Fixed MiniMax M3 dialect selection so MiniMax-family OpenAI-compatible models use the MiniMax tool-call dialect instead of generic XML. ([#2759](https://github.com/can1357/oh-my-pi/issues/2759)) - Fixed GitHub Copilot dynamic discovery to honor plan-specific API endpoints stored in structured OAuth credentials. ([#2876](https://github.com/can1357/oh-my-pi/issues/2876)) +- Added a `supportsImageDetailOriginal` compat flag that resolves to `false` for GitHub Copilot, whose Responses endpoint rejects the `detail: "original"` image hint with a 400, and `true` for every other host. ([#2822](https://github.com/can1357/oh-my-pi/issues/2822)) ## [16.0.4] - 2026-06-17 diff --git a/packages/catalog/src/compat/openai.ts b/packages/catalog/src/compat/openai.ts index 7b7362af1..30de10b33 100644 --- a/packages/catalog/src/compat/openai.ts +++ b/packages/catalog/src/compat/openai.ts @@ -459,6 +459,12 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol // Azure OpenAI and GitHub Copilot Responses paths require tool results // to strictly match prior tool calls when building Responses inputs. strictResponsesPairing: isAzure || spec.provider === "github-copilot", + // GitHub Copilot's Responses endpoint rejects the `detail: "original"` + // image hint with a 400; every other host preserves native-resolution + // frames (snapcompact relies on `original`). Detect Copilot by provider id + // or base-URL host (mirroring the Anthropic compat builder) so a model + // pointed at the Copilot host under a different provider id still clamps. + supportsImageDetailOriginal: !modelMatchesHost({ provider: spec.provider, baseUrl }, "githubCopilot"), requiresJuiceZeroHack: spec.name.toLowerCase().startsWith("gpt-5"), reasoningEffortMap: {}, supportsReasoningParams: true, @@ -514,6 +520,7 @@ function pickResponsesOnly(compat: ResolvedOpenAIResponsesCompat): ResponsesOnly return { supportsLongPromptCacheRetention: compat.supportsLongPromptCacheRetention, strictResponsesPairing: compat.strictResponsesPairing, + supportsImageDetailOriginal: compat.supportsImageDetailOriginal, requiresJuiceZeroHack: compat.requiresJuiceZeroHack, supportsObfuscationOptOut: compat.supportsObfuscationOptOut, } satisfies ResponsesOnlyCompat; diff --git a/packages/catalog/src/types.ts b/packages/catalog/src/types.ts index 5abe71911..2d5d0f574 100644 --- a/packages/catalog/src/types.ts +++ b/packages/catalog/src/types.ts @@ -283,6 +283,8 @@ export interface OpenAICompat { alwaysSendMaxTokens?: boolean; /** Whether Responses-API tool-call/result history must be strictly paired. Default: auto-detected (Azure OpenAI, GitHub Copilot). */ strictResponsesPairing?: boolean; + /** Whether the Responses API accepts the `detail: "original"` image hint. Default: auto-detected (false for GitHub Copilot, which rejects it with a 400). */ + supportsImageDetailOriginal?: boolean; /** * Append a trailing `# Juice: 0 !important` developer item when the caller * did not request reasoning, suppressing default reasoning on models that @@ -504,6 +506,7 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & | "cacheControlFormat" | "thinkingKeep" | "strictResponsesPairing" + | "supportsImageDetailOriginal" | "requiresJuiceZeroHack" | "enableGeminiThinkingLoopGuard" | "whenThinking" @@ -527,6 +530,7 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & export interface ResolvedOpenAIResponsesCompat extends ResolvedOpenAISharedCompat { supportsLongPromptCacheRetention: boolean; strictResponsesPairing: boolean; + supportsImageDetailOriginal: boolean; requiresJuiceZeroHack: boolean; supportsObfuscationOptOut: boolean; }