Files
OmniRoute/tests/unit/gemini-web-image-generation-10466.test.ts
Abhishek4512009 885cd8c411 feat(gemini-web): expose image generation through /v1/images/generations (closes #10466) (#10494)
* feat(providers): add Cloudflare AI Playground as No Auth provider (closes #10389)

Reverse-engineered access to the free, anonymous Cloudflare AI Playground:
chat runs over a PartySocket WebSocket speaking Cloudflare's cf_agent RPC
protocol with zero credentials (no account, no API key, no cookies). The
WS upgrade is gated on a browser-grade TLS fingerprint, so the executor
drives a headless Chromium via Playwright and speaks the protocol from
inside the page context.

- registry entry: cloudflare-playground (alias cfp), authType none,
  curated 20-model catalog (GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro,
  gpt-oss-120B, Llama 3.3 70B, Qwen2.5 Coder 32B, ...) captured from the
  live getModels RPC (2026-08-15)
- executor: cf_agent frame stream -> OpenAI SSE translation, id-filtered
  parser (RPC done:true frames cannot kill the stream), in-band upstream
  errors mapped to HTTP 429/502, abort + timeout handling, clean errors
- noauth UI entry with reverse-engineered-endpoint notice
- tests: 12 unit tests using real captured frames (incl. the 3021
  rate-limit error) + fake transport; ESLint clean; open-sse typecheck clean

* fix(providers): define __name helper in page context before evaluate

Bundlers with keepNames (esbuild/tsx, webpack) inject a __name() call into
serialized function bodies. page.evaluate(openPlaygroundSession) therefore
threw ReferenceError: __name is not defined in real browser sessions.
Define the helper on window before evaluating the session opener.

* fix(providers): sync docs counts, golden snapshots and add reasoning_content support for cloudflare-playground

* chore: remove ad-hoc cfp-shim debug script per review feedback

The standalone shim duplicated the executor's frame-parsing and transport
logic and is superseded by open-sse/executors/cloudflare-playground.ts.
Requested in PR #10442 review.

* feat(gemini-web): expose image generation through /v1/images/generations (closes #10466)

Adds a gemini-web image-generation path following the chatgpt-web precedent:

- imageRegistry: gemini-web provider entry (format gemini-web, cookie auth)
  with the nano-banana-web model. The -web suffix keeps the bare
  nano-banana id owned by adobe-firefly (operator decision 2026-07-31).
- gemini-web executor: new parseStreamResponseImages() extracts generated
  image URLs from the StreamGenerate candidate extension block
  (inner[4][0][12][7][0], url at entry[0][3][3] — string or list form),
  dedupes cumulative frames, upgrades to =s2048, and deliberately skips
  web-search thumbnails at [12][1]. Image mode (x_gemini_web_image_mode)
  captures every StreamGenerate frame, resolves on first image, and gets
  a 90s window; chat mode is byte-for-byte unchanged.
- handlers/imageGeneration/providers/geminiWeb.ts: drives the executor in
  image mode with an explicit generation directive prompt (the web UI
  otherwise answers with web-search images), caps n at 4, returns URLs or
  b64_json (downloads the public googleusercontent asset), and surfaces
  refusal text when no image was produced.
- Dispatch branch on format gemini-web in handleImageGeneration.

Tests: 21 new tests with fixtures built from the documented frame layout
(string/list url forms, cumulative-frame dedupe, web-image exclusion,
size-directive handling, refusal visibility, n-cap, b64_json, registry
wiring incl. the bare nano-banana → adobe-firefly regression guard).
Adjacent suites: gemini-web (6 files), chatgpt-web image, image handler,
route, registry, adobe-firefly, freepik, designer — all green.
ESLint clean on touched files (2 pre-existing any warnings unchanged);
tsc -p open-sse 0 errors.

* fix(media): close browser leak, surface timeout errors, and fall back accounts for gemini-web images

Addresses pre-merge review findings on #10494 (closes #10466):

- cloudflare-playground executor: close the launched browser on EVERY
  non-success start() path, including the detected Cloudflare "Attention
  Required" challenge branch (was leaking a Chromium process per blocked
  request).
- cloudflare-playground executor: a streaming chat timeout now emits an
  explicit timeout_error SSE chunk before [DONE] instead of silently
  completing, so a client can no longer mistake an empty/partial timed-out
  stream for a successful answer. Timeout duration is now injectable for
  deterministic tests.
- gemini-web image handler + imageCredentialRetry: classify the underlying
  GeminiWebExecutor's expired/blocked-session failure modes (400/500, per
  its own Playwright timeout/catch-all branches) as retryable, so
  executeImageWithCredentialFallback advances to the next eligible account
  instead of only doing so on a plain 401.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* docs: regenerate provider counts after merging release/v3.8.50 (341 -> 342)

The previous merge commit resolved all 51 auto-generated-file conflicts by
taking release/v3.8.50's content, which still said 341 providers. Merging in
this branch's Cloudflare Playground provider brings the live catalog to 342,
so npm run check:docs-counts-sync now flags stale claims. Fix:

- docs/reference/PROVIDER_REFERENCE.md: regenerated via
  `npm run gen:provider-reference`.
- README.md/AGENTS.md/llm.txt/package.json description: 341 -> 342.
- docs/diagrams/{readme-hero,promise-pillars,comparison-table,cli-terminal}.svg:
  341 -> 342 in the embedded "NNN providers" text (targeted replace, matched
  against the exact pattern check-docs-counts-sync.mjs validates).

check:docs-counts-sync and check:changelog-integrity are both clean after
this commit.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* docs(env): document CLOUDFLARE_PLAYGROUND_CHROME_PATH

Used by open-sse/executors/cloudflare-playground.ts but missing from
.env.example and docs/reference/ENVIRONMENT.md, caught by the
env-doc-sync gate when combined with other PRs in the release
merge-train.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: user.email <freakymustard67@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 11:43:16 -03:00

321 lines
12 KiB
TypeScript

// 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");
});