mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:49:49 +00:00
fix(cli): report bounded vision failures
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user