fix(cli): report bounded vision failures

This commit is contained in:
Miguel Ángel
2026-07-31 20:30:07 +00:00
parent 22e3cca966
commit dfc60797a2
4 changed files with 218 additions and 52 deletions
@@ -86,17 +86,35 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response("invalid api key", { status: 401, statusText: "Unauthorized" }),
async () =>
new Response("provider detail containing or-bad-key", {
status: 401,
statusText: "Unauthorized",
}),
),
);
const warnings: string[] = [];
// captionOne throws on !res.ok, but the throw is per-image inside
// Promise.allSettled, so it's filtered out as a rejected result rather than
// bubbling up — same silent degradation as the existing Gemini path.
const captions = await captionImagesWithGemini(dir, () => {}, warnings);
let outcome: VisionCaptionOutcome | undefined;
const captions = await captionImagesWithGemini(dir, () => {}, warnings, {
onOutcome: (value) => {
outcome = value;
},
});
expect(captions).toEqual({});
expect(warnings).toEqual(["OpenRouter vision failed for 1 asset(s); captions omitted."]);
expect(warnings.join(" ")).not.toContain("or-bad-key");
expect(outcome).toEqual({
timedOutRequests: 0,
failedRequests: 1,
budgetExhausted: false,
});
if (!outcome) throw new Error("Expected vision caption outcome");
expect(resolveVisionPhaseCompletion(outcome, 10_000)).toEqual({
status: "degraded",
reason: "provider-error",
});
});
it("terminates captioning when the vision provider never responds", async () => {
@@ -125,6 +143,94 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
expect(result).toEqual({});
});
it.each([
{ label: "success JSON", status: 200, partialBody: '{"choices":[' },
{ label: "failure text", status: 500, partialBody: "upstream failed: " },
])(
"terminates captioning when OpenRouter $label body never ends",
async ({ status, partialBody }) => {
const dir = makeProjectWithImages();
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "or-stalled-body-key");
vi.stubEnv("HYPERFRAMES_VISION_TIMEOUT_MS", "20");
vi.stubGlobal(
"fetch",
vi.fn(async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(partialBody));
},
});
return new Response(body, {
status,
headers: { "content-type": "application/json" },
});
}),
);
const warnings: string[] = [];
let outcome: VisionCaptionOutcome | undefined;
const result = await Promise.race([
captionImagesWithGemini(dir, () => {}, warnings, {
onOutcome: (value) => {
outcome = value;
},
}),
new Promise<"hung">((resolve) => setTimeout(() => resolve("hung"), 250)),
]);
expect(result).toEqual({});
expect(warnings).toEqual(["OpenRouter vision timed out for 1 asset(s); captions omitted."]);
expect(outcome).toEqual({
timedOutRequests: 1,
failedRequests: 0,
budgetExhausted: false,
});
},
);
it("keeps successful captions while reporting a failed sibling request", async () => {
const dir = makeProjectWithImages(["failed.png", "success.png"]);
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "or-mixed-failure-key");
let requestCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => {
requestCount++;
if (requestCount === 1) {
return new Response("upstream exploded with or-mixed-failure-key", {
status: 500,
statusText: "Internal Server Error",
});
}
return new Response(
JSON.stringify({ choices: [{ message: { content: "Successful image." } }] }),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
const warnings: string[] = [];
let outcome: VisionCaptionOutcome | undefined;
const captions = await captionImagesWithGemini(dir, () => {}, warnings, {
onOutcome: (value) => {
outcome = value;
},
});
expect(captions).toEqual({ "success.png": "Successful image." });
expect(warnings).toEqual(["OpenRouter vision failed for 1 asset(s); captions omitted."]);
expect(warnings.join(" ")).not.toContain("or-mixed-failure-key");
expect(outcome).toEqual({
timedOutRequests: 0,
failedRequests: 1,
budgetExhausted: false,
});
});
it("keeps a successful sibling caption when another request times out", async () => {
const dir = makeProjectWithImages(["hang.png", "success.png"]);
dirs.push(dir);
@@ -163,7 +269,11 @@ describe("captionImagesWithGemini — OpenRouter provider", () => {
});
expect(captions).toEqual({ "success.png": "Successful image." });
expect(outcome).toEqual({ timedOutRequests: 1, budgetExhausted: false });
expect(outcome).toEqual({
timedOutRequests: 1,
failedRequests: 0,
budgetExhausted: false,
});
if (!outcome) throw new Error("Expected vision caption outcome");
expect(resolveVisionPhaseCompletion(outcome, 10_000)).toEqual({
status: "degraded",
@@ -242,4 +352,36 @@ describe("captionImagesWithGemini — Gemini provider", () => {
expect(result).toEqual({});
});
it("reports a rejected Gemini request without leaking its error detail", async () => {
const dir = makeProjectWithImages();
dirs.push(dir);
vi.stubEnv("OPENROUTER_API_KEY", "");
vi.stubEnv("GEMINI_API_KEY", "gemini-secret-key");
generateContentMock.mockRejectedValue(
new Error("provider echoed gemini-secret-key in its response"),
);
const warnings: string[] = [];
let outcome: VisionCaptionOutcome | undefined;
const captions = await captionImagesWithGemini(dir, () => {}, warnings, {
onOutcome: (value) => {
outcome = value;
},
});
expect(captions).toEqual({});
expect(warnings).toEqual(["Gemini vision failed for 1 asset(s); captions omitted."]);
expect(warnings.join(" ")).not.toContain("gemini-secret-key");
expect(outcome).toEqual({
timedOutRequests: 0,
failedRequests: 1,
budgetExhausted: false,
});
if (!outcome) throw new Error("Expected vision caption outcome");
expect(resolveVisionPhaseCompletion(outcome, 10_000)).toEqual({
status: "degraded",
reason: "provider-error",
});
});
});
+68 -45
View File
@@ -19,6 +19,7 @@ const DEFAULT_VISION_REQUEST_TIMEOUT_MS = 30_000;
export interface VisionCaptionOutcome {
timedOutRequests: number;
failedRequests: number;
budgetExhausted: boolean;
}
@@ -33,13 +34,19 @@ export function resolveVisionPhaseCompletion(
remainingMs: number,
):
| { status: "completed" }
| { status: "degraded"; reason: "budget-exhausted" | "request-timeout" } {
| {
status: "degraded";
reason: "budget-exhausted" | "request-timeout" | "provider-error";
} {
if (outcome.budgetExhausted || remainingMs <= 0) {
return { status: "degraded", reason: "budget-exhausted" };
}
if (outcome.timedOutRequests > 0) {
return { status: "degraded", reason: "request-timeout" };
}
if (outcome.failedRequests > 0) {
return { status: "degraded", reason: "provider-error" };
}
return { status: "completed" };
}
@@ -236,9 +243,14 @@ export async function captionImagesWithGemini(
): Promise<Record<string, string>> {
const geminiCaptions: Record<string, string> = {};
let timedOutCount = 0;
let failedRequestCount = 0;
let budgetExhausted = false;
const reportOutcome = (): void => {
options.onOutcome?.({ timedOutRequests: timedOutCount, budgetExhausted });
options.onOutcome?.({
timedOutRequests: timedOutCount,
failedRequests: failedRequestCount,
budgetExhausted,
});
};
if (options.skipVision) {
reportOutcome();
@@ -279,42 +291,40 @@ export async function captionImagesWithGemini(
let captionOne: CaptionOne;
if (openRouterKey) {
captionOne = async ({ mimeType, base64, prompt, maxTokens, timeoutMs }) => {
const res = await runBoundedVisionRequest(
(signal) =>
fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${openRouterKey}`,
"Content-Type": "application/json",
},
signal,
body: JSON.stringify({
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: prompt },
{
type: "image_url",
image_url: { url: `data:${mimeType};base64,${base64}` },
},
],
},
],
max_tokens: maxTokens,
}),
return runBoundedVisionRequest(async (signal) => {
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${openRouterKey}`,
"Content-Type": "application/json",
},
signal,
body: JSON.stringify({
model,
messages: [
{
role: "user",
content: [
{ type: "text", text: prompt },
{
type: "image_url",
image_url: { url: `data:${mimeType};base64,${base64}` },
},
],
},
],
max_tokens: maxTokens,
}),
timeoutMs,
);
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`OpenRouter ${res.status} ${res.statusText}: ${detail.slice(0, 200)}`);
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
return data.choices?.[0]?.message?.content?.trim() || "";
});
if (!res.ok) {
await res.text();
throw new Error(`OpenRouter request failed with HTTP ${res.status}`);
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
return data.choices?.[0]?.message?.content?.trim() || "";
}, timeoutMs);
};
} else {
// Unreachable when geminiKey is unset (guarded above); re-narrow for TS.
@@ -350,13 +360,14 @@ export async function captionImagesWithGemini(
// Caption in parallel batches. Gemini free tier is ~5 RPM (slow but $0),
// paid/OpenRouter ~2000 RPM. We batch 20 with a 2s inter-batch pause and rely
// on Promise.allSettled so a rate-limited image degrades to "" rather than
// failing the batch.
// on Promise.allSettled so one rate-limited image does not fail its siblings;
// rejected requests are aggregated into a sanitized warning below.
const BATCH_SIZE = 20;
const collectCaptionResults = (
results: PromiseSettledResult<{ file: string; caption: string }>[],
): number => {
): { timeouts: number; failures: number } => {
let timeouts = 0;
let failures = 0;
for (const result of results) {
if (result.status === "fulfilled" && result.value.caption) {
geminiCaptions[result.value.file] = result.value.caption;
@@ -365,9 +376,11 @@ export async function captionImagesWithGemini(
result.reason instanceof VisionRequestTimeoutError
) {
timeouts++;
} else if (result.status === "rejected") {
failures++;
}
}
return timeouts;
return { timeouts, failures };
};
for (let i = 0; i < imageFiles.length; i += BATCH_SIZE) {
const remainingMs = options.remainingMs?.() ?? Number.POSITIVE_INFINITY;
@@ -397,7 +410,9 @@ export async function captionImagesWithGemini(
return { file, caption };
}),
);
timedOutCount += collectCaptionResults(results);
const counts = collectCaptionResults(results);
timedOutCount += counts.timeouts;
failedRequestCount += counts.failures;
// Pace requests between batches (paid tier: 2000+ RPM, free tier: rate-limited)
if (i + BATCH_SIZE < imageFiles.length) {
await new Promise((r) => setTimeout(r, 2000)); // 2s pause between batches — paid tier handles 2000 RPM, free tier retries via Promise.allSettled
@@ -501,7 +516,9 @@ export async function captionImagesWithGemini(
return { file: relPath, caption };
}),
);
timedOutCount += collectCaptionResults(results);
const counts = collectCaptionResults(results);
timedOutCount += counts.timeouts;
failedRequestCount += counts.failures;
if (i + SVG_BATCH < svgFiles.length) {
await new Promise((r) => setTimeout(r, 2000));
}
@@ -523,8 +540,14 @@ export async function captionImagesWithGemini(
`${providerName} vision timed out for ${timedOutCount} asset(s); captions omitted.`,
);
}
} catch (err) {
warnings.push(`${providerName} captioning failed: ${err}`);
if (failedRequestCount > 0) {
warnings.push(
`${providerName} vision failed for ${failedRequestCount} asset(s); captions omitted.`,
);
}
} catch {
failedRequestCount = Math.max(1, failedRequestCount);
warnings.push(`${providerName} captioning failed; captions omitted.`);
}
reportOutcome();
+1
View File
@@ -684,6 +684,7 @@ export async function captureWebsite(
phase("vision", "started");
let visionOutcome: VisionCaptionOutcome = {
timedOutRequests: 0,
failedRequests: 0,
budgetExhausted: false,
};
geminiCaptions = await captionImagesWithGemini(outputDir, progress, warnings, {
+1 -1
View File
@@ -26,7 +26,7 @@ export interface CapturePhaseProgress {
status: "started" | "completed" | "degraded";
/** Null before the post-navigation budget begins. */
remainingMs: number | null;
reason?: "budget-exhausted" | "disabled" | "request-timeout";
reason?: "budget-exhausted" | "disabled" | "request-timeout" | "provider-error";
}
export interface CaptureOptions {