Merge PR #2902: fix(ai): degrade Copilot Responses image detail original to auto (@wolfiesch)

This commit is contained in:
can1357
2026-06-18 21:53:06 +02:00
11 changed files with 186 additions and 8 deletions
+1
View File
@@ -92,6 +92,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,
+25 -3
View File
@@ -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);
}
+20 -2
View File
@@ -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);
+1
View File
@@ -61,6 +61,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
+7
View File
@@ -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;
+4
View File
@@ -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;
}