mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
fix(sse): retire Gemini Web image generation
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }), {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
131
tests/unit/gemini-web-image-retirement.test.ts
Normal file
131
tests/unit/gemini-web-image-retirement.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user