From 3c4a95745fd2b0b4e428e96fa4e77b3110a5f107 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Wed, 26 Aug 2026 18:45:00 -0300 Subject: [PATCH] fix(sse): retire Gemini Web image generation --- config/quality/test-masking-allowlist.json | 12 +- open-sse/config/imageRegistry.ts | 25 +- open-sse/executors/gemini-web.ts | 141 +------- open-sse/handlers/imageGeneration.ts | 16 +- .../imageGeneration/providers/geminiWeb.ts | 229 ------------- src/sse/services/imageCredentialRetry.ts | 12 +- .../gemini-web-image-account-fallback.test.ts | 174 ---------- .../gemini-web-image-generation-10466.test.ts | 320 ------------------ .../unit/gemini-web-image-retirement.test.ts | 131 +++++++ 9 files changed, 163 insertions(+), 897 deletions(-) delete mode 100644 open-sse/handlers/imageGeneration/providers/geminiWeb.ts delete mode 100644 tests/unit/gemini-web-image-account-fallback.test.ts delete mode 100644 tests/unit/gemini-web-image-generation-10466.test.ts create mode 100644 tests/unit/gemini-web-image-retirement.test.ts diff --git a/config/quality/test-masking-allowlist.json b/config/quality/test-masking-allowlist.json index 30f386c186..a1c59c24b4 100644 --- a/config/quality/test-masking-allowlist.json +++ b/config/quality/test-masking-allowlist.json @@ -25,6 +25,14 @@ "tests/unit/providers-page-utils.test.ts": "v3.8.47 #6675: remoção dos providers obsoletos glhf/kluster/cablyai/inclusionai — os 2 asserts que citavam providers removidos do catálogo foram removidos junto (net 276→274). Superfície aposentada, não mascaramento. Verificado legítimo. Prune após v3.8.47 mergear para main.", "_deletedWithReplacement": { "_comment": "Deleções de arquivo de teste com SUBSTITUTO verificado (o gate exige que o replacement exista no HEAD e seja arquivo de teste). Uso restrito ao caso 'reescrito em outro path sem rename detectável pelo -M do git'. Cada entrada precisa de reason com PR ref e passa por revisão humana no release PR. Prune após o release mergear para main.", + "tests/unit/gemini-web-image-account-fallback.test.ts": { + "replacement": "tests/unit/gemini-web-image-retirement.test.ts", + "reason": "v3.8.50 pending PR: a capacidade Gemini Web Images foi aposentada em HOLD de procedencia/licenca, sem aposentar Gemini Web chat/auth/tools. O teste positivo de fallback da integracao removida foi substituido pelo guard negativo no catalogo/handler e pelo controle que preserva o executor/registry de chat e provedores legitimos de Nano Banana. Superficie extinta, nao mascaramento; atualizar com o numero da PR apos abri-la." + }, + "tests/unit/gemini-web-image-generation-10466.test.ts": { + "replacement": "tests/unit/gemini-web-image-retirement.test.ts", + "reason": "v3.8.50 pending PR: parser, image-mode, handler e modelo gemini-web/nano-banana-web foram aposentados em HOLD de procedencia/licenca. O replacement prova a ausencia no catalogo e request path, a remocao dos artefatos dedicados, a neutralizacao da antiga extensao image-mode e a preservacao de Gemini Web chat e imagens Gemini/Nano Banana em provedores legitimos. Superficie extinta, nao mascaramento; atualizar com o numero da PR apos abri-la." + }, "open-sse/services/combo/__tests__/targetExhaustion.test.ts": { "replacement": "tests/unit/combo/combo-target-exhaustion.test.ts", "reason": "v3.8.44 #5976: os testes de exaustão eram flake-prone (delays Math.random, timeouts 30s, >3min no CI) e foram REESCRITOS como unit determinístico com MAIS cobertura (21 casos/52 asserts vs 13 casos/37 asserts). Documentado no commit 5fe225850. Revisão humana: apresentado ao operador no STOP #1 do release v3.8.44." @@ -74,9 +82,7 @@ "reason": "v3.8.50 #9126 (commit 8fac6bcd48): pluginWorker.ts, sandbox.ts e signing.ts foram removidos por completo (\"zero importers confirmed\") — o subsistema de sandbox de plugins com worker-thread nunca foi ligado a nenhum consumidor. O teste era source-scan sobre pluginWorker.ts (ver docstring do arquivo deletado); sem o arquivo-fonte não há mais o que testar. OMNIROUTE_PLUGINS_ALLOW_EXEC também foi removido de .env.example e da doc na mesma release. Sem substituto porque a feature foi extinta, não migrada." }, "tests/unit/plugins-sandbox.test.ts": { - "sourceRemoved": [ - "src/lib/plugins/sandbox.ts" - ], + "sourceRemoved": ["src/lib/plugins/sandbox.ts"], "reason": "v3.8.50 #9126 (commit 8fac6bcd48): sandbox.ts foi removido por completo junto com pluginWorker.ts e signing.ts (\"zero importers confirmed\", subsistema de sandbox de plugins nunca ligado a nenhum consumidor). O teste cobria SandboxLevel/getSandboxLabel exportados por sandbox.ts; sem o arquivo-fonte não há mais símbolo a testar. Mesma causa-raiz de tests/unit/plugin-sandbox-permissions.test.ts nesta entrada." }, "tests/unit/gemini-3-5-flash-thinking.test.ts": { diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 02019dc4a0..c33b298d3d 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -248,26 +248,6 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], }, - // #10466: Gemini Web session image generation (Nano Banana). Same - // web-cookie transport as the gemini-web chat provider — the handler - // drives the session executor in image mode and extracts the generated - // asset URLs from the StreamGenerate frames. - "gemini-web": { - id: "gemini-web", - alias: "gweb", - baseUrl: "https://gemini.google.com/app", - authType: "apikey", - authHeader: "cookie", - format: "gemini-web", - // `-web` suffix on purpose: the bare `nano-banana` id is owned by - // adobe-firefly (operator decision 2026-07-31, pinned by the - // cheaperinference-image-models guard). parseImageModel's bare-model scan - // walks providers in insertion order, so a bare `nano-banana` here would - // steal that resolution. Keep this id distinct. - models: [{ id: "nano-banana-web", name: "Nano Banana (Gemini Web Image)" }], - supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], - }, - // Cursor plan image generation via the Agent CLI native `generateImage` tool. // Reuses the same OAuth/API-key connection as chat (`provider: "cursor"`). // Requires the `agent` binary (CURSOR_AGENT_BIN) — see cursorAgentImage handler. @@ -925,7 +905,10 @@ export function parseImageModel(modelStr) { // No provider prefix — try to find the model in every provider, excluding cookie-auth (web) bridges for (const [providerId, config] of Object.entries(IMAGE_PROVIDERS)) { - if (config.authHeader !== "cookie" && (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr))) { + if ( + config.authHeader !== "cookie" && + (config.routingAliases?.includes(modelStr) || config.models.some((m) => m.id === modelStr)) + ) { return { provider: providerId, model: modelStr }; } } diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index 18dd7af008..afc678b876 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -264,70 +264,6 @@ export function parseStreamResponse(raw: string): string { return lastText; } -/** - * Extract generated-image URLs from a Gemini StreamGenerate response (#10466). - * - * When the web UI generates images (Nano Banana), the model's answer frames - * carry the assets in the candidate's extension block, NOT in the text: - * - * inner[4][0][12][7][0] → array of generated-image entries - * entry[0][3][3] → the image URL — either a plain string or a - * list of strings (take the first http(s) one) - * - * This path is corroborated by the two maintained reverse-engineered clients - * (gpt4free's Gemini provider and HanaokaYuzu/Gemini-API's _parse_candidate). - * Deliberately NOT collected: `inner[4][0][12][1]` — those are web-search - * result thumbnails, not generated content; mixing them in would serve - * scraped images as "generated" (#10466 acceptance criteria). - * - * Frames are cumulative snapshots, so later frames repeat earlier images; - * we dedupe while preserving first-seen order. A `=s2048` size suffix is - * appended (gpt4free's proven heuristic) so callers get full-resolution - * assets instead of UI thumbnails. - */ -export function parseStreamResponseImages(raw: string): string[] { - const urls: string[] = []; - const seen = new Set(); - const lines = raw.split("\n"); - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || line === ")]}'" || /^\d+$/.test(line)) continue; - if (!line.includes("wrb.fr")) continue; - try { - const arr = JSON.parse(line); - if (!Array.isArray(arr) || !Array.isArray(arr[0]) || arr[0][0] !== "wrb.fr") continue; - const payload = arr[0]?.[2]; - if (typeof payload !== "string") continue; - const inner = JSON.parse(payload); - const imageEntries = inner?.[4]?.[0]?.[12]?.[7]?.[0]; - if (!Array.isArray(imageEntries)) continue; - for (const entry of imageEntries) { - const urlField = entry?.[0]?.[3]?.[3]; - let url = ""; - if (typeof urlField === "string") { - url = urlField; - } else if (Array.isArray(urlField)) { - const firstHttp = urlField.find( - (u: unknown) => typeof u === "string" && /^https?:\/\//.test(u) - ); - url = typeof firstHttp === "string" ? firstHttp : ""; - } - if (!url || !/^https?:\/\//.test(url)) continue; - // Upgrade to full resolution unless a size directive is already present - // (googleusercontent size syntax: trailing `=s2048`, `=w1024-h512`, ...). - if (!/=[swh]\d+/.test(url)) url += "=s2048"; - if (seen.has(url)) continue; - seen.add(url); - urls.push(url); - } - } catch { - // Skip unparseable lines - } - } - return urls; -} - function readCredentialString(value: unknown): string { if (typeof value !== "string") return ""; const trimmed = value.trim(); @@ -572,52 +508,24 @@ export class GeminiWebExecutor extends BaseExecutor { const page = await context.newPage(); - // #10466: image mode — the /v1/images/generations handler sets - // x_gemini_web_image_mode. Generated images arrive in the candidate's - // extension block ([12][7][0]) of the StreamGenerate frames, sometimes - // only in a LATER frame of the stream (or a follow-up StreamGenerate - // call), so image mode captures every StreamGenerate response, merges - // image URLs across frames, and resolves as soon as one is found. - // Chat mode keeps the original first-response-only behavior. - const imageMode = (body as Record)?.x_gemini_web_image_mode === true; - // Capture first StreamGenerate response let responseText = ""; - const responseImages: string[] = []; let captured = false; const responsePromise = new Promise((resolve) => { page.on("response", async (resp: any) => { if (!resp.url().includes("StreamGenerate")) return; - if (!imageMode && captured) return; - if (imageMode) { - // Image mode: merge text + image URLs across every frame and - // resolve as soon as an image appears (images can land in a - // later frame than the text). - try { - const raw = await resp.text(); - const text = parseStreamResponse(raw); - if (text) responseText = text; - for (const url of parseStreamResponseImages(raw)) { - if (!responseImages.includes(url)) responseImages.push(url); - } - } catch { - /* ignore unreadable frames */ - } - if (responseImages.length > 0) resolve(); - } else { - // Chat mode: byte-for-byte the original first-response capture — - // resolve even if reading the body throws, so the flow falls - // through to the "No response from Gemini" 502 instead of - // burning the full wait window. - captured = true; - try { - const raw = await resp.text(); - responseText = parseStreamResponse(raw); - } catch { - /* ignore */ - } - resolve(); + if (captured) return; + // Resolve even if reading the body throws, so the flow falls through + // to the "No response from Gemini" 502 instead of burning the full + // wait window. + captured = true; + try { + const raw = await resp.text(); + responseText = parseStreamResponse(raw); + } catch { + /* ignore */ } + resolve(); }); }); @@ -636,36 +544,11 @@ export class GeminiWebExecutor extends BaseExecutor { await page.waitForTimeout(300); await page.keyboard.press("Enter"); - // Wait for response or timeout. Image generation (Nano Banana) is - // noticeably slower than text — the UI renders the asset only after - // the full generation completes — so image mode gets a wider window. - await Promise.race([responsePromise, page.waitForTimeout(imageMode ? 90000 : 30000)]); + await Promise.race([responsePromise, page.waitForTimeout(30000)]); if (signal?.aborted) { throw signal.reason instanceof Error ? signal.reason : new Error("Request aborted"); } - // #10466 image mode: return the captured image URLs to the image - // handler via a custom field (same precedent as chatgpt-web's - // x_image_resolution_failed). An image-only answer can carry little or - // no text, so the empty-text 502 below must not fire when images - // were captured. - if (imageMode) { - await this.persistRotatedCookies(context, cookie, credentials, onCredentialsRefreshed, log); - const modelId = model || "gemini-2.5-pro"; - return { - response: new Response( - JSON.stringify({ - ...formatChatCompletion(responseText, modelId), - x_gemini_web_image_urls: responseImages, - }), - { status: 200, headers: { "Content-Type": "application/json" } } - ), - url: GEMINI_URL, - headers: {}, - transformedBody: body, - }; - } - if (!responseText) { return { response: new Response(JSON.stringify({ error: "No response from Gemini" }), { diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index db0745898e..d7539d88c4 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -50,7 +50,6 @@ import { extractMarkdownImageUrls, CHATGPT_WEB_IMAGE_ID_RE, } from "./imageGeneration/providers/chatgptWeb.ts"; -import { handleGeminiWebImageGeneration } from "./imageGeneration/providers/geminiWeb.ts"; import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts"; import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts"; @@ -533,19 +532,6 @@ export async function handleImageGeneration({ }); } - // #10466: Gemini Web session image generation (Nano Banana) - if (providerConfig.format === "gemini-web") { - return handleGeminiWebImageGeneration({ - model, - provider, - body, - credentials, - log, - signal, - clientHeaders, - }); - } - if (providerConfig.format === "cursor-agent-image") { return handleCursorAgentImageGeneration({ model, @@ -2851,7 +2837,7 @@ export function saveImageErrorResult({ error, requestBody = null, path = "/v1/images/generations", - // #10494: opt-in signal for executeImageWithCredentialFallback — set by a + // #8307: opt-in signal for executeImageWithCredentialFallback — set by a // provider handler when the failure is account/session-specific (expired // or blocked credentials) rather than a generic request/provider error, so // the retry loop tries the next eligible account even when the upstream diff --git a/open-sse/handlers/imageGeneration/providers/geminiWeb.ts b/open-sse/handlers/imageGeneration/providers/geminiWeb.ts deleted file mode 100644 index 8f131fe83d..0000000000 --- a/open-sse/handlers/imageGeneration/providers/geminiWeb.ts +++ /dev/null @@ -1,229 +0,0 @@ -// Gemini Web image generation handler (#10466). -// -// Exposes the gemini-web session provider through POST /v1/images/generations. -// Follows the chatgpt-web precedent (./chatgptWeb.ts): the web-session chat -// executor is driven with an image-generation prompt, and the generated -// assets are extracted from the response. -// -// Transport: GeminiWebExecutor in image mode (x_gemini_web_image_mode). The -// executor types the prompt into gemini.google.com, captures every -// StreamGenerate frame, and returns generated-image URLs in the custom -// `x_gemini_web_image_urls` field. URLs point at lh3.googleusercontent.com -// with a `=s2048` full-resolution size directive; they are public (no -// cookies needed to fetch them). -// -// Prompting: the web UI only GENERATES images when the prompt uses a -// generation verb ("generate"/"create"/"draw"); otherwise it answers with -// web-search thumbnails. The prompt builder therefore always leads with an -// explicit generation directive (corroborated by gemini-webapi's docs). - -import { GeminiWebExecutor } from "../../../executors/gemini-web.ts"; -import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; -import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; -import { sanitizeErrorMessage } from "../../../utils/error.ts"; - -/** Each image is one gemini.google.com turn (~30-60s). Cap like chatgpt-web. */ -const GEMINI_WEB_IMAGE_N_MAX = 4; - -export function buildGeminiWebImagePrompt(body: Record): string { - const prompt = String(body.prompt || "").trim(); - const details: string[] = [ - `Generate an image for this prompt: ${prompt}`, - "Use the image generation model. Do not search the web for existing images.", - ]; - if (typeof body.size === "string" && body.size.trim()) { - details.push(`Requested aspect/size: ${body.size.trim()}.`); - } - if (typeof body.style === "string" && body.style.trim()) { - details.push(`Requested style: ${body.style.trim()}.`); - } - return details.join("\n"); -} - -/** - * #10494: the underlying GeminiWebExecutor's browser-automation catch paths - * classify an expired/blocked Gemini Web session as HTTP 400 ("the session - * is so expired it lands on a different page" — see gemini-web.ts's - * Playwright selector/click-timeout branch, #9407) or HTTP 500 (its generic - * automation-failure catch-all, which covers a blocked/CAPTCHA/login page - * this handler has no further way to inspect). Both statuses previously - * passed straight through to executeImageWithCredentialFallback, which only - * advances to another account on a plain 401 — so an expired/blocked - * session never triggered account fallback, contrary to #10466's - * acceptance criteria ("Expired or blocked sessions ... can fall back - * normally inside an image Combo"). HTTP 503 (missing Playwright browser — - * a host/config problem, not a per-account issue) is intentionally excluded, - * as is the local 401 this handler already returns before any account is - * selected (missing session cookie — handled by the 401 path already). - */ -export function isExpiredOrBlockedGeminiWebSession(status: number): boolean { - return status === 400 || status === 500; -} - -export async function handleGeminiWebImageGeneration({ - model, - provider, - body, - credentials, - log, - signal, - clientHeaders, - // Injectable so unit tests can drive the handler without a live Gemini - // session; production uses the real executor. - executorFactory = () => new GeminiWebExecutor(), - // Injectable for tests; production fetches the public googleusercontent URL. - imageFetcher = fetchRemoteImage, -}: { - model: string; - provider: string; - body: Record; - credentials: Record | null | undefined; - log: { - info: (scope: string, message: string) => void; - warn: (scope: string, message: string) => void; - error: (scope: string, message: string) => void; - } | null; - signal?: AbortSignal | null; - clientHeaders?: Record | null; - executorFactory?: () => { - execute: (input: Record) => Promise<{ response: Response }>; - }; - imageFetcher?: (url: string) => Promise<{ buffer: Buffer; contentType: string }>; -}) { - const startTime = Date.now(); - const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; - if (!prompt) { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: "Prompt is required for Gemini Web image generation", - }); - } - - if (!credentials?.apiKey) { - return saveImageErrorResult({ - provider, - model, - status: 401, - startTime, - error: "Gemini Web credentials missing session cookie", - }); - } - - const rawCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; - if (rawCount > GEMINI_WEB_IMAGE_N_MAX) { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: `Gemini Web image generation supports n=1..${GEMINI_WEB_IMAGE_N_MAX} (got ${rawCount}); each n is a separate ~30-60s web turn.`, - }); - } - const requestedCount = rawCount; - if (log && requestedCount > 1) { - log.warn( - "IMAGE", - `Gemini Web returns image(s) per chat turn; requested n=${requestedCount} will run sequentially` - ); - } - - const wantsBase64 = body.response_format === "b64_json"; - const images: Array<{ url?: string; b64_json?: string }> = []; - const requestBody = { - model, - prompt: prompt.slice(0, 500), - size: body.size || undefined, - n: requestedCount, - }; - - for (let i = 0; i < requestedCount; i++) { - const executor = executorFactory(); - const result = await executor.execute({ - model, - body: { - messages: [{ role: "user", content: buildGeminiWebImagePrompt(body) }], - x_gemini_web_image_mode: true, - }, - stream: false, - credentials, - signal, - log, - clientHeaders, - }); - - const responseText = await result.response.text(); - if (result.response.status >= 400) { - return saveImageErrorResult({ - provider, - model, - status: result.response.status, - startTime, - error: responseText, - requestBody, - retryable: isExpiredOrBlockedGeminiWebSession(result.response.status), - }); - } - - let content = ""; - let urls: string[] = []; - try { - const json = JSON.parse(responseText); - content = String(json?.choices?.[0]?.message?.content || ""); - urls = Array.isArray(json?.x_gemini_web_image_urls) - ? (json.x_gemini_web_image_urls as unknown[]).filter( - (u): u is string => typeof u === "string" && /^https?:\/\//.test(u) - ) - : []; - } catch { - content = responseText; - } - - if (urls.length === 0) { - // Distinguish "refused / no image produced" from a transport failure: - // the executor returns 200 with an empty URL list when the model - // answered with text only (e.g. a policy refusal or a web-search - // answer instead of generation). Surface the assistant text so the - // caller can see WHY nothing was generated. - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: `Gemini Web completed without generating an image. Assistant text: ${content.slice(0, 300) || "(empty)"}`, - requestBody, - }); - } - - for (const url of urls) { - if (!wantsBase64) { - images.push({ url }); - continue; - } - try { - const fetched = await imageFetcher(url); - images.push({ b64_json: fetched.buffer.toString("base64") }); - } catch (err) { - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: `Gemini Web generated an image but OmniRoute could not download it for b64_json conversion: ${sanitizeErrorMessage(err instanceof Error ? err.message : String(err))}`, - requestBody, - }); - } - } - } - - return saveImageSuccessResult({ - provider, - model, - startTime, - requestBody, - responseBody: { images_count: images.length }, - images, - }); -} diff --git a/src/sse/services/imageCredentialRetry.ts b/src/sse/services/imageCredentialRetry.ts index 5758aed108..6f804bbf1f 100644 --- a/src/sse/services/imageCredentialRetry.ts +++ b/src/sse/services/imageCredentialRetry.ts @@ -9,13 +9,13 @@ interface ImageGenerationResult { status?: number; error?: unknown; data?: unknown; - // #10494: opt-in signal a provider handler can set (via + // #8307: opt-in signal a provider handler can set (via // saveImageErrorResult's `retryable` option) when a non-401 failure is - // still account/session-specific — e.g. an expired or blocked Gemini Web - // session, which the underlying browser-automation executor surfaces as - // 400/500 rather than 401. Only honored together with a connectionId, same - // as the existing 401 path, so providers that never set it keep the - // original 401-only fallback behavior unchanged. + // still account-specific — e.g. a ChatGPT account that can run Codex but + // lacks access to the requested image model and returns a specific 400. + // Only honored together with a connectionId, same as the existing 401 path, + // so providers that never set it keep the original 401-only fallback + // behavior unchanged. retryable?: boolean; } diff --git a/tests/unit/gemini-web-image-account-fallback.test.ts b/tests/unit/gemini-web-image-account-fallback.test.ts deleted file mode 100644 index 3be797f6b6..0000000000 --- a/tests/unit/gemini-web-image-account-fallback.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// #10494: Gemini Web image-generation account fallback gap. -// -// #10466's acceptance criteria require that "expired or blocked sessions -// return a clear session/provider error and can fall back normally inside an -// image Combo." The gemini-web image handler passed the executor's raw HTTP -// status straight through to executeImageWithCredentialFallback, whose retry -// loop only advances to the next account on a plain HTTP 401 — but the -// underlying GeminiWebExecutor's browser-automation catch paths surface an -// expired/blocked session as 400 (Playwright selector/click timeout — "the -// session is so expired it lands on a different page", #9407) or 500 (the -// generic automation-failure catch-all), never 401. So expired/blocked -// Gemini Web sessions never triggered account fallback. -// -// Covers: -// - isExpiredOrBlockedGeminiWebSession() classification (unit). -// - A multi-account regression: first account fails with a classified -// status, the retry loop advances to a second account, which succeeds. -// - An invalid-session test that drives the REAL GeminiWebExecutor (Playwright -// launch mocked, same technique as tests/unit/gemini-web.test.ts) so the -// classified status is the executor's actual status code, not a synthetic -// one, and confirms the handler marks it retryable end to end. -import test from "node:test"; -import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-geminiweb-image-fallback-")); -process.env.DATA_DIR = TEST_DATA_DIR; - -const { isExpiredOrBlockedGeminiWebSession, handleGeminiWebImageGeneration } = await import( - "../../open-sse/handlers/imageGeneration/providers/geminiWeb.ts" -); -const { executeImageWithCredentialFallback } = await import( - "../../src/sse/services/imageCredentialRetry.ts" -); -const { GeminiWebExecutor } = await import("../../open-sse/executors/gemini-web.ts"); -const core = await import("../../src/lib/db/core.ts"); - -test.after(() => { - core.resetDbInstance(); - rmSync(TEST_DATA_DIR, { recursive: true, force: true }); -}); - -// ── Classification (unit) ─────────────────────────────────────────────────── - -test("isExpiredOrBlockedGeminiWebSession classifies 400/500 as retryable, everything else as not", () => { - assert.equal(isExpiredOrBlockedGeminiWebSession(400), true); - assert.equal(isExpiredOrBlockedGeminiWebSession(500), true); - assert.equal(isExpiredOrBlockedGeminiWebSession(401), false, "handled by the plain 401 path"); - assert.equal( - isExpiredOrBlockedGeminiWebSession(503), - false, - "missing-Playwright-browser is a host/config problem, not a per-account issue" - ); - assert.equal(isExpiredOrBlockedGeminiWebSession(502), false); - assert.equal(isExpiredOrBlockedGeminiWebSession(200), false); -}); - -// ── Multi-account regression: 2 accounts, first classified-fails, second succeeds ── - -test("executeImageWithCredentialFallback: expired/blocked (400) on account 1 falls back to account 2", async () => { - const attempts: string[] = []; - const accountA = { connectionId: "conn-a", apiKey: "cookie-a" }; - const accountB = { connectionId: "conn-b", apiKey: "cookie-b" }; - - const execution = await executeImageWithCredentialFallback({ - provider: "gemini-web", - requestedModel: "gemini-2.5-pro", - credentials: accountA, - // Simulates the real handler path: geminiWeb.ts sets retryable via - // saveImageErrorResult when the executor status is classified as an - // expired/blocked session (400/500), not just a plain 401. - execute: async (creds) => { - attempts.push(creds.connectionId); - if (creds.connectionId === "conn-a") { - return { success: false, status: 400, error: "session expired", retryable: true }; - } - return { success: true, data: { created: 1, data: [{ url: "https://example/img.png" }] } }; - }, - selectNextCredentials: async () => accountB, - }); - - assert.deepEqual(attempts, ["conn-a", "conn-b"], "must try both accounts in order"); - assert.equal(execution.result.success, true); - assert.equal(execution.credentials.connectionId, "conn-b"); -}); - -test("executeImageWithCredentialFallback: a non-retryable 400 (e.g. bad prompt) does NOT burn a second account", async () => { - const attempts: string[] = []; - const accountA = { connectionId: "conn-a", apiKey: "cookie-a" }; - - const execution = await executeImageWithCredentialFallback({ - provider: "gemini-web", - requestedModel: "gemini-2.5-pro", - credentials: accountA, - execute: async (creds) => { - attempts.push(creds.connectionId); - return { success: false, status: 400, error: "Prompt is required" }; // retryable unset - }, - selectNextCredentials: async () => { - throw new Error("must not be called for a non-retryable failure"); - }, - }); - - assert.deepEqual(attempts, ["conn-a"]); - assert.equal(execution.result.success, false); - assert.equal(execution.result.status, 400); -}); - -// ── Invalid-session test against the REAL executor's actual status code ──── - -test("handler classifies the REAL GeminiWebExecutor's session-expired 400 as retryable", async () => { - const playwright = await import("playwright"); - const originalLaunch = playwright.chromium.launch; - - // Mirrors tests/unit/gemini-web.test.ts's pattern for a fake page whose - // waitForSelector() times out — the exact path (#9407) that makes the - // real executor return a 400 tagged "the session is so expired it lands - // on a different page". - playwright.chromium.launch = (async () => - ({ - newContext: async () => ({ - addCookies: async () => {}, - newPage: async () => ({ - on: () => {}, - goto: async () => {}, - waitForTimeout: async () => {}, - waitForSelector: async () => { - const err = new Error("Timeout 10000ms exceeded while waiting for selector"); - err.name = "TimeoutError"; - throw err; - }, - }), - }), - close: async () => {}, - }) as unknown as ReturnType) as typeof playwright.chromium.launch; - - try { - const executor = new GeminiWebExecutor(); - const direct = await executor.execute({ - model: "gemini-2.5-pro", - body: { messages: [{ role: "user", content: "hi" }], x_gemini_web_image_mode: true }, - stream: false, - credentials: { apiKey: "expired-session-cookie" }, - signal: AbortSignal.timeout(10000), - log: null, - }); - // Confirm the REAL executor really does surface this as 400 (not a - // synthetic status invented by the test). - assert.equal(direct.response.status, 400, "sanity: executor's real session-expired status"); - - const res = await handleGeminiWebImageGeneration({ - model: "gemini-2.5-pro", - provider: "gemini-web", - body: { prompt: "a kitten" }, - credentials: { apiKey: "expired-session-cookie", connectionId: "conn-real" }, - log: null, - signal: null, - clientHeaders: {}, - executorFactory: () => new GeminiWebExecutor(), - }); - - assert.equal(res.success, false); - assert.equal(res.status, 400); - assert.equal( - (res as { retryable?: boolean }).retryable, - true, - "the handler must mark the real executor's session-expired status as retryable" - ); - } finally { - playwright.chromium.launch = originalLaunch; - } -}); diff --git a/tests/unit/gemini-web-image-generation-10466.test.ts b/tests/unit/gemini-web-image-generation-10466.test.ts deleted file mode 100644 index 252329e8dd..0000000000 --- a/tests/unit/gemini-web-image-generation-10466.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -// Tests for gemini-web image generation (#10466). -// -// Fixtures are built from the documented StreamGenerate frame layout for -// generated images (corroborated by gpt4free's Gemini provider and -// HanaokaYuzu/Gemini-API's _parse_candidate): -// -// wrb.fr line → JSON [ "wrb.fr", null, "" ] -// payload → JSON [ ..., [4] = [ candidate ] ] -// candidate[1] = [ "answer text" ] -// candidate[12][1] = web-search images (must NOT be collected) -// candidate[12][7][0] = generated-image entries -// entry[0][3][3] = image URL (string OR list of strings) -import test from "node:test"; -import assert from "node:assert/strict"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-gweb-image-")); - -const { parseStreamResponse, parseStreamResponseImages } = - await import("../../open-sse/executors/gemini-web.ts"); -const { handleGeminiWebImageGeneration, buildGeminiWebImagePrompt } = - await import("../../open-sse/handlers/imageGeneration/providers/geminiWeb.ts"); -const { parseImageModel, getImageProvider } = - await import("../../open-sse/config/imageRegistry.ts"); - -// ─── Fixture builders ─────────────────────────────────────────────────────── - -/** Build one wrb.fr StreamGenerate line with the given candidate. */ -function frameLine(candidate: unknown): string { - const payload = JSON.stringify([null, [], null, null, [candidate]]); - return JSON.stringify([["wrb.fr", null, payload]]); -} - -/** Candidate carrying answer text and/or generated images. */ -function candidate({ - text = "", - generatedUrls = [], - webImageUrls = [], -}: { - text?: string; - generatedUrls?: Array; - webImageUrls?: string[]; -} = {}): unknown[] { - const cand: unknown[] = []; - cand[1] = [text]; - if (webImageUrls.length > 0 || generatedUrls.length > 0) { - const ext: unknown[] = []; - if (webImageUrls.length > 0) { - // [12][1]: web-search result thumbnails — [[ [url, ...], ... ]] - ext[1] = webImageUrls.map((u) => [[[u]]]); - } - if (generatedUrls.length > 0) { - // [12][7][0]: generated-image entries; parser reads entry[0][3][3] = url - ext[7] = [generatedUrls.map((u) => [[null, null, null, [null, null, null, u]]])]; - } - cand[12] = ext; - } - return cand; -} - -function streamResponse(lines: string[]): string { - return [")]}'", ...lines.map((l) => `${l.length}\n${l}`)].join("\n"); -} - -const IMG_URL = "https://lh3.googleusercontent.com/gg-dl/generated-abc123"; -const IMG_URL_2 = "https://lh3.googleusercontent.com/gg-dl/generated-def456"; -const WEB_URL = "https://example.com/web-search-thumb.jpg"; - -// ─── parseStreamResponseImages ────────────────────────────────────────────── - -test("extracts generated-image URL from a realistic frame (string form)", () => { - const raw = streamResponse([ - frameLine(candidate({ text: "Here you go!", generatedUrls: [IMG_URL] })), - ]); - assert.deepEqual(parseStreamResponseImages(raw), [`${IMG_URL}=s2048`]); -}); - -test("handles list-form URL field (takes first http entry)", () => { - const raw = streamResponse([ - frameLine(candidate({ generatedUrls: [["not-a-url", IMG_URL, IMG_URL_2]] })), - ]); - assert.deepEqual(parseStreamResponseImages(raw), [`${IMG_URL}=s2048`]); -}); - -test("dedupes across cumulative frames, preserving first-seen order", () => { - // Frames are cumulative snapshots: frame 2 repeats image 1 and adds image 2. - const raw = streamResponse([ - frameLine(candidate({ text: "partial", generatedUrls: [IMG_URL] })), - frameLine(candidate({ text: "full answer", generatedUrls: [IMG_URL, IMG_URL_2] })), - ]); - assert.deepEqual(parseStreamResponseImages(raw), [`${IMG_URL}=s2048`, `${IMG_URL_2}=s2048`]); -}); - -test("does NOT collect web-search images at [12][1]", () => { - const raw = streamResponse([ - frameLine(candidate({ text: "found these", webImageUrls: [WEB_URL] })), - ]); - assert.deepEqual(parseStreamResponseImages(raw), []); -}); - -test("does not double-append size directive when one is present", () => { - const sized = `${IMG_URL}=w1024-h512`; - const raw = streamResponse([frameLine(candidate({ generatedUrls: [sized] }))]); - assert.deepEqual(parseStreamResponseImages(raw), [sized]); -}); - -test("returns [] for text-only frames (chat responses unaffected)", () => { - const raw = streamResponse([frameLine(candidate({ text: "just text, no images" }))]); - assert.deepEqual(parseStreamResponseImages(raw), []); -}); - -test("skips malformed lines without throwing", () => { - const raw = [ - ")]}'", - "garbage not json", - JSON.stringify([["wrb.fr", null, "{broken json"]]), - frameLine(candidate({ generatedUrls: [IMG_URL] })), - ].join("\n"); - assert.deepEqual(parseStreamResponseImages(raw), [`${IMG_URL}=s2048`]); -}); - -test("text parser still extracts text from image-bearing frames", () => { - const raw = streamResponse([ - frameLine(candidate({ text: "Here is your image!", generatedUrls: [IMG_URL] })), - ]); - assert.equal(parseStreamResponse(raw), "Here is your image!"); -}); - -// ─── buildGeminiWebImagePrompt ────────────────────────────────────────────── - -test("prompt leads with an explicit generation directive", () => { - const prompt = buildGeminiWebImagePrompt({ prompt: "a red panda", size: "1024x1536" }); - assert.match(prompt, /^Generate an image for this prompt: a red panda/); - assert.match(prompt, /Do not search the web/); - assert.match(prompt, /1024x1536/); -}); - -// ─── handleGeminiWebImageGeneration ───────────────────────────────────────── - -function fakeExecutor(jsonBody: object, status = 200) { - return { - execute: async () => ({ - response: new Response(JSON.stringify(jsonBody), { - status, - headers: { "Content-Type": "application/json" }, - }), - }), - }; -} - -const baseArgs = { - model: "nano-banana-web", - provider: "gemini-web", - body: { prompt: "a red panda eating bamboo" }, - credentials: { apiKey: "***" }, - log: null, - signal: null, - clientHeaders: {}, -}; - -test("success: returns image URLs in OpenAI image response shape", async () => { - const res = await handleGeminiWebImageGeneration({ - ...baseArgs, - executorFactory: () => - fakeExecutor({ - choices: [{ message: { role: "assistant", content: "Here you go!" } }], - x_gemini_web_image_urls: [IMG_URL], - }), - }); - assert.equal(res.success, true); - assert.equal(res.data.data.length, 1); - assert.equal(res.data.data[0].url, IMG_URL); - assert.ok(res.data.created > 0); -}); - -test("success: b64_json downloads the image via injected fetcher", async () => { - const bytes = Buffer.from("fake-png-bytes"); - const res = await handleGeminiWebImageGeneration({ - ...baseArgs, - body: { prompt: "a red panda", response_format: "b64_json" }, - executorFactory: () => - fakeExecutor({ - choices: [{ message: { role: "assistant", content: "" } }], - x_gemini_web_image_urls: [IMG_URL], - }), - imageFetcher: async (url: string) => { - assert.equal(url, IMG_URL); - return { buffer: bytes, contentType: "image/png" }; - }, - }); - assert.equal(res.success, true); - assert.equal(res.data.data[0].b64_json, bytes.toString("base64")); - assert.equal(res.data.data[0].url, undefined); -}); - -test("b64_json download failure surfaces a specific 502", async () => { - const res = await handleGeminiWebImageGeneration({ - ...baseArgs, - body: { prompt: "a red panda", response_format: "b64_json" }, - executorFactory: () => - fakeExecutor({ - choices: [{ message: { role: "assistant", content: "" } }], - x_gemini_web_image_urls: [IMG_URL], - }), - imageFetcher: async () => { - throw new Error("Remote image fetch error 403"); - }, - }); - assert.equal(res.success, false); - assert.equal(res.status, 502); - assert.match(res.error, /generated an image but OmniRoute could not download it/); -}); - -test("no images generated: 502 includes assistant text (refusal visibility)", async () => { - const res = await handleGeminiWebImageGeneration({ - ...baseArgs, - executorFactory: () => - fakeExecutor({ - choices: [{ message: { role: "assistant", content: "I can't generate that image." } }], - x_gemini_web_image_urls: [], - }), - }); - assert.equal(res.success, false); - assert.equal(res.status, 502); - assert.match(res.error, /without generating an image/); - assert.match(res.error, /I can't generate that image/); -}); - -test("missing prompt → 400", async () => { - const res = await handleGeminiWebImageGeneration({ - ...baseArgs, - body: { prompt: " " }, - }); - assert.equal(res.success, false); - assert.equal(res.status, 400); -}); - -test("missing cookie → 401", async () => { - const res = await handleGeminiWebImageGeneration({ - ...baseArgs, - credentials: {}, - }); - assert.equal(res.success, false); - assert.equal(res.status, 401); -}); - -test("n above the cap → 400 with the cap named", async () => { - const res = await handleGeminiWebImageGeneration({ - ...baseArgs, - body: { prompt: "a red panda", n: 5 }, - }); - assert.equal(res.success, false); - assert.equal(res.status, 400); - assert.match(res.error, /n=1\.\.4/); -}); - -test("executor error status passes through", async () => { - const res = await handleGeminiWebImageGeneration({ - ...baseArgs, - executorFactory: () => fakeExecutor({ error: "Missing Gemini cookies" }, 401), - }); - assert.equal(res.success, false); - assert.equal(res.status, 401); -}); - -test("n=2 runs sequentially and collects both turns' images", async () => { - let calls = 0; - const res = await handleGeminiWebImageGeneration({ - ...baseArgs, - body: { prompt: "a red panda", n: 2 }, - executorFactory: () => ({ - execute: async () => { - calls++; - const url = calls === 1 ? IMG_URL : IMG_URL_2; - return { - response: new Response( - JSON.stringify({ - choices: [{ message: { role: "assistant", content: "" } }], - x_gemini_web_image_urls: [url], - }), - { status: 200, headers: { "Content-Type": "application/json" } } - ), - }; - }, - }), - }); - assert.equal(calls, 2); - assert.equal(res.success, true); - assert.deepEqual( - res.data.data.map((d: { url?: string }) => d.url), - [IMG_URL, IMG_URL_2] - ); -}); - -// ─── Registry wiring ──────────────────────────────────────────────────────── - -test("registry: gemini-web/nano-banana resolves to the gemini-web provider", () => { - const parsed = parseImageModel("gemini-web/nano-banana-web"); - assert.equal(parsed.provider, "gemini-web"); - assert.equal(parsed.model, "nano-banana-web"); - const config = getImageProvider("gemini-web"); - assert.ok(config); - assert.equal(config.format, "gemini-web"); - assert.equal(config.authHeader, "cookie"); -}); - -test("registry: alias gweb/nano-banana resolves too", () => { - const parsed = parseImageModel("gweb/nano-banana-web"); - assert.equal(parsed.provider, "gemini-web"); - assert.equal(parsed.model, "nano-banana-web"); -}); - -test("registry regression: bare nano-banana still routes to adobe-firefly", () => { - // adobe-firefly owns the bare nano-banana ids (operator decision 2026-07-31); - // the new gemini-web entry must not steal that resolution. - const parsed = parseImageModel("nano-banana"); - assert.equal(parsed.provider, "adobe-firefly"); -}); diff --git a/tests/unit/gemini-web-image-retirement.test.ts b/tests/unit/gemini-web-image-retirement.test.ts new file mode 100644 index 0000000000..1e4e36ded9 --- /dev/null +++ b/tests/unit/gemini-web-image-retirement.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import test from "node:test"; + +const { getAllImageModels, getImageProvider, parseImageModel } = + await import("../../open-sse/config/imageRegistry.ts"); +const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); +const geminiWebExecutorModule = await import("../../open-sse/executors/gemini-web.ts"); +const { GeminiWebExecutor } = geminiWebExecutorModule; +const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); +const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); + +test("Gemini Web image generation is absent from the public image catalog and request path", async () => { + assert.equal(getImageProvider("gemini-web"), null); + assert.equal(getImageProvider("gweb"), null); + assert.equal( + getAllImageModels().some( + ({ provider, id }) => provider === "gemini-web" || id === "gemini-web/nano-banana-web" + ), + false + ); + + const result = await handleImageGeneration({ + body: { + model: "gemini-web/nano-banana-web", + prompt: "a red panda eating bamboo", + }, + credentials: { apiKey: "unused-cookie" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 400); + assert.match(result.error, /Invalid image model/); +}); + +test("Gemini Web executor treats the retired image-mode extension as ordinary chat input", async () => { + const playwright = await import("playwright"); + const originalLaunch = playwright.chromium.launch; + const waitDurations: number[] = []; + + const candidate: unknown[] = []; + candidate[1] = ["standard chat reply"]; + const extensions: unknown[] = []; + extensions[7] = [[[[null, null, null, "https://lh3.googleusercontent.com/retired-image"]]]]; + candidate[12] = extensions; + const payload = JSON.stringify([null, [], null, null, [candidate]]); + const raw = `)]}'\n42\n${JSON.stringify([["wrb.fr", null, payload]])}\n`; + + playwright.chromium.launch = (async () => + ({ + newContext: async () => ({ + addCookies: async () => {}, + newPage: async () => ({ + on: ( + event: string, + handler: (response: { url: () => string; text: () => Promise }) => void + ) => { + if (event === "response") { + void handler({ + url: () => "https://gemini.google.com/_/StreamGenerate", + text: async () => raw, + }); + } + }, + goto: async () => {}, + waitForTimeout: async (duration: number) => { + waitDurations.push(duration); + }, + waitForSelector: async () => ({ click: async () => {} }), + keyboard: { type: async () => {}, press: async () => {} }, + }), + }), + close: async () => {}, + }) as unknown as Awaited>) as typeof originalLaunch; + + try { + const result = await new GeminiWebExecutor().execute({ + model: "gemini-3.1-pro", + body: { + messages: [{ role: "user", content: "hello" }], + x_gemini_web_image_mode: true, + }, + stream: false, + credentials: { apiKey: "fake-cookie=abc" }, + signal: AbortSignal.timeout(5000), + log: null, + }); + + assert.equal(result.response.status, 200); + const json = (await result.response.json()) as Record; + assert.equal(json.x_gemini_web_image_urls, undefined); + assert.equal( + (json.choices as Array<{ message: { content: string } }>)[0].message.content, + "standard chat reply" + ); + assert.equal(waitDurations.includes(90_000), false); + assert.equal(waitDurations.includes(30_000), true); + } finally { + playwright.chromium.launch = originalLaunch; + } +}); + +test("Gemini Web image-only parser and handler artifacts are retired", () => { + assert.equal("parseStreamResponseImages" in geminiWebExecutorModule, false); + assert.equal( + existsSync( + new URL("../../open-sse/handlers/imageGeneration/providers/geminiWeb.ts", import.meta.url) + ), + false + ); +}); + +test("Gemini Web chat and legitimate Gemini image providers remain available", () => { + assert.equal(hasSpecializedExecutor("gemini-web"), true); + assert.equal(getExecutor("gemini-web").getProvider(), "gemini-web"); + assert.equal(REGISTRY["gemini-web"]?.executor, "gemini-web"); + assert.deepEqual( + REGISTRY["gemini-web"]?.models.map(({ id }) => id), + ["gemini-3.1-pro", "gemini-3.7-flash", "gemini-3.1-flash-lite"] + ); + + assert.deepEqual(parseImageModel("nano-banana"), { + provider: "adobe-firefly", + model: "nano-banana", + }); + assert.deepEqual(parseImageModel("openrouter/google/gemini-3-pro-image-preview"), { + provider: "openrouter", + model: "google/gemini-3-pro-image-preview", + }); +});