fix(sse): retire Gemini Web image generation

This commit is contained in:
Xiangzhe
2026-08-26 18:45:00 -03:00
parent 091589089c
commit 3c4a95745f
9 changed files with 163 additions and 897 deletions

View File

@@ -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": {

View File

@@ -248,26 +248,6 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
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 };
}
}

View File

@@ -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<string>();
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<string, unknown>)?.x_gemini_web_image_mode === true;
// Capture first StreamGenerate response
let responseText = "";
const responseImages: string[] = [];
let captured = false;
const responsePromise = new Promise<void>((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" }), {

View File

@@ -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

View File

@@ -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, unknown>): 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<string, unknown>;
credentials: Record<string, unknown> | 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<string, string> | null;
executorFactory?: () => {
execute: (input: Record<string, unknown>) => 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,
});
}

View File

@@ -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;
}

View File

@@ -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<typeof playwright.chromium.launch>) 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;
}
});

View File

@@ -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>" ]
// 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<string | string[]>;
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");
});

View File

@@ -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<string> }) => 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<ReturnType<typeof originalLaunch>>) 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<string, unknown>;
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",
});
});