diff --git a/packages/cli/src/capture/contentExtractor.test.ts b/packages/cli/src/capture/contentExtractor.test.ts index fa1e077611..d392305048 100644 --- a/packages/cli/src/capture/contentExtractor.test.ts +++ b/packages/cli/src/capture/contentExtractor.test.ts @@ -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({ + 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", + }); + }); }); diff --git a/packages/cli/src/capture/contentExtractor.ts b/packages/cli/src/capture/contentExtractor.ts index c5a3f2eb61..7b9a909a61 100644 --- a/packages/cli/src/capture/contentExtractor.ts +++ b/packages/cli/src/capture/contentExtractor.ts @@ -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> { const geminiCaptions: Record = {}; 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(); diff --git a/packages/cli/src/capture/index.ts b/packages/cli/src/capture/index.ts index fa5627de83..15d8e3d4ae 100644 --- a/packages/cli/src/capture/index.ts +++ b/packages/cli/src/capture/index.ts @@ -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, { diff --git a/packages/cli/src/capture/types.ts b/packages/cli/src/capture/types.ts index 2ecacf390b..9efda669d6 100644 --- a/packages/cli/src/capture/types.ts +++ b/packages/cli/src/capture/types.ts @@ -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 {