From 3b1c0b9be76ff4ece67193ce3b1d78830bb18983 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Tue, 10 Nov 2026 18:49:36 -0800 Subject: [PATCH 1/2] fix(capture): validate the Vertex region and model id before captioning Vision captioning passes the region and model id to the Google SDK, which builds the request host from the region and the request path from the model id. A region that is not a plain Google Cloud region name, or a model id with URL delimiters, now skips captioning with a warning instead of building a request. Every real region (including "global") and model id still works. Co-Authored-By: Claude Opus 5.5 --- .../cli/src/capture/contentExtractor.test.ts | 59 +++++++++++++++++++ packages/cli/src/capture/contentExtractor.ts | 30 +++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/capture/contentExtractor.test.ts b/packages/cli/src/capture/contentExtractor.test.ts index 5d27c089f1..1a6cc37561 100644 --- a/packages/cli/src/capture/contentExtractor.test.ts +++ b/packages/cli/src/capture/contentExtractor.test.ts @@ -556,6 +556,65 @@ describe("captionImagesWithGemini — Vertex AI provider", () => { expect(clientOptions[0]).toMatchObject({ location: "europe-west4" }); }); + it.each([ + "attacker.example/", + "x.attacker.example#", + "a@attacker.example", + "attacker.example:443?", + "us-central1.attacker.example", + ])("skips captioning for a location that is not a region: %s", async (location) => { + const dir = makeProjectWithImages(); + dirs.push(dir); + vertexEnv(); + vi.stubEnv("HYPERFRAMES_VERTEX_LOCATION", location); + + const warnings: string[] = []; + const captions = await captionImagesWithGemini(dir, () => {}, warnings); + + expect(captions).toEqual({}); + expect(clientOptions).toHaveLength(0); + expect(generateContentMock).not.toHaveBeenCalled(); + expect(warnings).toEqual([ + "HYPERFRAMES_VERTEX_LOCATION is not a Google Cloud region; skipped vision captioning.", + ]); + }); + + it.each([ + "../../v1/other", + "gemini?alt=x", + "gemini#x", + "https://attacker.example/m", + "/models/x", + ])("skips captioning for a model that is not a model id: %s", async (model) => { + const dir = makeProjectWithImages(); + dirs.push(dir); + vertexEnv(); + vi.stubEnv("HYPERFRAMES_VERTEX_MODEL", model); + + const warnings: string[] = []; + const captions = await captionImagesWithGemini(dir, () => {}, warnings); + + expect(captions).toEqual({}); + expect(clientOptions).toHaveLength(0); + expect(warnings).toEqual([ + "HYPERFRAMES_VERTEX_MODEL is not a model id; skipped vision captioning.", + ]); + }); + + it.each(["global", "us", "europe-west4"])("accepts the region %s", async (location) => { + const dir = makeProjectWithImages(); + dirs.push(dir); + vertexEnv(); + vi.stubEnv("HYPERFRAMES_VERTEX_LOCATION", location); + vi.stubEnv("HYPERFRAMES_VERTEX_MODEL", "publishers/google/models/gemini-2.5-flash-001"); + generateContentMock.mockResolvedValue({ text: "A caption." }); + + const captions = await captionImagesWithGemini(dir, () => {}, []); + + expect(captions).toEqual({ "hero.png": "A caption." }); + expect(clientOptions[0]).toMatchObject({ location }); + }); + it("spends no output budget on thinking", async () => { // Thinking tokens come out of maxOutputTokens, so a model left free to think can consume // the whole budget and return empty text: a successful request that produces no caption. diff --git a/packages/cli/src/capture/contentExtractor.ts b/packages/cli/src/capture/contentExtractor.ts index 60b85c2de2..7e8665e068 100644 --- a/packages/cli/src/capture/contentExtractor.ts +++ b/packages/cli/src/capture/contentExtractor.ts @@ -109,6 +109,26 @@ function vertexCredentials(raw: string): Record | string { : "HYPERFRAMES_VERTEX_SERVICE_ACCOUNT is not a service-account key; skipped vision captioning."; } +// The SDK builds the request host from the location and the request path from the model id. +const VERTEX_LOCATION = /^[a-z][a-z0-9-]*$/; +const GOOGLE_MODEL_ID = /^[A-Za-z0-9][\w.@-]*(?:\/[A-Za-z0-9][\w.@-]*)*$/; + +function googleClientSettingsProblem( + provider: "vertex" | "gemini", + model: string, + location: string, +): string | undefined { + if (provider === "vertex" && !VERTEX_LOCATION.test(location)) { + return "HYPERFRAMES_VERTEX_LOCATION is not a Google Cloud region; skipped vision captioning."; + } + if (!GOOGLE_MODEL_ID.test(model)) { + const variable = + provider === "vertex" ? "HYPERFRAMES_VERTEX_MODEL" : "HYPERFRAMES_GEMINI_MODEL"; + return `${variable} is not a model id; skipped vision captioning.`; + } + return undefined; +} + function resolveVisionRequestTimeoutMs(): number { const configured = Number(process.env.HYPERFRAMES_VISION_TIMEOUT_MS); return Number.isFinite(configured) && configured > 0 @@ -398,6 +418,14 @@ export async function captionImagesWithGemini( }, timeoutMs); }; } else { + const vertexLocation = process.env.HYPERFRAMES_VERTEX_LOCATION || "us-central1"; + const settingsProblem = googleClientSettingsProblem(provider, model, vertexLocation); + if (settingsProblem) { + warnings.push(settingsProblem); + internalError = true; + reportOutcome(); + return geminiCaptions; + } let GoogleGenAI: OptionalPackageModules["@google/genai"]["GoogleGenAI"]; try { ({ GoogleGenAI } = await loadOptionalPackage("@google/genai", "vision captioning")); @@ -421,7 +449,7 @@ export async function captionImagesWithGemini( ai = new GoogleGenAI({ vertexai: true, project: vertexProject, - location: process.env.HYPERFRAMES_VERTEX_LOCATION || "us-central1", + location: vertexLocation, googleAuthOptions: { credentials }, }); } else { From 9bca2d98401a4ad88c588eba5298c3869e7590b4 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Tue, 10 Nov 2026 19:02:02 -0800 Subject: [PATCH 2/2] fix(capture): accept colons inside Google model id segments Vertex endpoint paths in domain-scoped projects contain a colon (projects/example.com:proj/...). The colon cannot change the request host, so allow it inside a segment. Leading slashes, empty segments, percent signs, query and fragment delimiters stay rejected. Co-Authored-By: Claude Opus 5.5 --- .../cli/src/capture/contentExtractor.test.ts | 20 ++++++++++++++++++- packages/cli/src/capture/contentExtractor.ts | 3 ++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/capture/contentExtractor.test.ts b/packages/cli/src/capture/contentExtractor.test.ts index 1a6cc37561..1a02584c5e 100644 --- a/packages/cli/src/capture/contentExtractor.test.ts +++ b/packages/cli/src/capture/contentExtractor.test.ts @@ -601,7 +601,25 @@ describe("captionImagesWithGemini — Vertex AI provider", () => { ]); }); - it.each(["global", "us", "europe-west4"])("accepts the region %s", async (location) => { + it.each([ + "gemini-2.5-flash", + "google/gemini-2.5-flash", + "publishers/anthropic/models/claude-sonnet-4@20250514", + "projects/example.com:proj/locations/us-central1/endpoints/123", + ])("accepts the model id %s", async (model) => { + const dir = makeProjectWithImages(); + dirs.push(dir); + vertexEnv(); + vi.stubEnv("HYPERFRAMES_VERTEX_MODEL", model); + generateContentMock.mockResolvedValue({ text: "A caption." }); + + const captions = await captionImagesWithGemini(dir, () => {}, []); + + expect(captions).toEqual({ "hero.png": "A caption." }); + expect(generateContentMock.mock.calls[0]?.[0]).toMatchObject({ model }); + }); + + it.each(["global", "us", "eu", "europe-west4"])("accepts the region %s", async (location) => { const dir = makeProjectWithImages(); dirs.push(dir); vertexEnv(); diff --git a/packages/cli/src/capture/contentExtractor.ts b/packages/cli/src/capture/contentExtractor.ts index 7e8665e068..2239693335 100644 --- a/packages/cli/src/capture/contentExtractor.ts +++ b/packages/cli/src/capture/contentExtractor.ts @@ -110,8 +110,9 @@ function vertexCredentials(raw: string): Record | string { } // The SDK builds the request host from the location and the request path from the model id. +// `:` stays legal inside a segment for endpoint paths in domain-scoped projects (`example.com:proj`). const VERTEX_LOCATION = /^[a-z][a-z0-9-]*$/; -const GOOGLE_MODEL_ID = /^[A-Za-z0-9][\w.@-]*(?:\/[A-Za-z0-9][\w.@-]*)*$/; +const GOOGLE_MODEL_ID = /^[A-Za-z0-9][\w.:@-]*(?:\/[A-Za-z0-9][\w.:@-]*)*$/; function googleClientSettingsProblem( provider: "vertex" | "gemini",