mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 00:52:18 +03:00
* 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>
321 lines
12 KiB
TypeScript
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");
|
|
});
|