fix(ai): degrade Copilot Responses image detail "original" to auto
The Responses-API "original" image detail is an oh-my-pi extension that preserves native resolution (snapcompact renders terminal-glyph frames that do not survive the "auto" downscale). GitHub Copilot's Responses endpoint rejects detail: "original" with an HTTP 400, so any request carrying such an image failed outright. - Add a supportsImageDetailOriginal compat flag in the catalog, resolved to false whenever the model resolves to the Copilot host (by provider id or base-URL marker, via modelMatchesHost, mirroring the Anthropic compat builder) and true for every other host, alongside the existing strictResponsesPairing Responses-only flag (same three type touch-points: optional on OpenAICompat, omitted from the chat-completions resolved type, required on the Responses resolved type). - Clamp the image detail hint to "auto" when the host does not support "original" at both Responses image-emission sites (user content and tool-result content). Every other host preserves native-resolution frames, so snapcompact is unaffected. Host-aware detection (rather than a bare provider-string check) keeps the clamp in force when a model is pointed at the Copilot Responses host under a different provider id, which would otherwise reintroduce the 400; a regression test covers that path. Closes #2822 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<minimax:tool_call>` 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
|
||||
|
||||
|
||||
@@ -297,6 +297,7 @@ function buildParams(
|
||||
model,
|
||||
context,
|
||||
strictResponsesPairing: true,
|
||||
supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal,
|
||||
systemRole,
|
||||
includeThinkingSignatures: true,
|
||||
developerStringContent: true,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -687,6 +687,7 @@ export function buildParams(
|
||||
model,
|
||||
context,
|
||||
strictResponsesPairing,
|
||||
supportsImageDetailOriginal: model.compat.supportsImageDetailOriginal,
|
||||
nativeHistory: {
|
||||
replay: shouldReplayNativeHistory,
|
||||
filterReasoning: policy.reasoning.filterReasoningHistory,
|
||||
|
||||
@@ -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<TextContent | ImageContent>,
|
||||
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<TApi extends Api> {
|
||||
model: Model<TApi>;
|
||||
context: Context;
|
||||
strictResponsesPairing: boolean;
|
||||
supportsImageDetailOriginal: boolean;
|
||||
systemRole?: "system" | "developer";
|
||||
nativeHistory?: {
|
||||
replay: boolean;
|
||||
@@ -1267,7 +1283,11 @@ export function buildResponsesInput<TApi extends Api>(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<TApi extends Api>(options: BuildResponsesInp
|
||||
msg,
|
||||
options.model,
|
||||
options.strictResponsesPairing,
|
||||
options.supportsImageDetailOriginal,
|
||||
knownCallIds,
|
||||
customCallIds,
|
||||
);
|
||||
@@ -1419,6 +1440,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
|
||||
toolResult: ToolResultMessage,
|
||||
model: Model<TApi>,
|
||||
strictResponsesPairing: boolean,
|
||||
supportsImageDetailOriginal: boolean,
|
||||
knownCallIds: ReadonlySet<string>,
|
||||
customCallIds?: ReadonlySet<string>,
|
||||
): void {
|
||||
@@ -1475,7 +1497,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -659,8 +659,17 @@ describe("history replay: custom_tool_call round-trip", () => {
|
||||
};
|
||||
const knownCallIds = new Set<string>(["call_1"]);
|
||||
const customCallIds = new Set<string>(["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<string>(["call_2"]);
|
||||
const customCallIds = new Set<string>(); // 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");
|
||||
|
||||
@@ -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<TApi extends Api>(spec: Partial<ModelSpec<TApi>> & { api: TApi }): Model<TApi> {
|
||||
@@ -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, unknown>): 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<string, unknown>;
|
||||
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<string | undefined> {
|
||||
let body: Record<string, unknown> | 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user