From e6087c62b275c8042d64c5fcd44c2ec976c7559c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:28:12 -0300 Subject: [PATCH] fix(sse): anonymous fingerprint fallback for keyless Pollinations image gen (#8085) (#8157) Pollinations image requests with no configured apiKey/accessToken (the common free case) were sent with no Authorization header AND no fingerprint headers, so Pollinations' own upstream legitimately rejected them with a real 401 even for a valid OmniRoute key. The chat path already has an anonymous fingerprint-pool fallback (PollinationsExecutor.execute()'s isAnonymous branch); the image path never reused it. Adds open-sse/handlers/imageGeneration/pollinationsAnonAuth.ts, mirroring the chat executor's anonymous session-pool fallback for handleOpenAIImageGeneration, and fixes the pre-existing bug where Authorization was set to the literal string "Bearer undefined" when no token was configured (now correctly gated by if (token)). Regression test: tests/unit/pollinations-image-anon-fallback-8085.test.ts --- .../fixes/8085-images-anon-fallback.md | 1 + open-sse/handlers/imageGeneration.ts | 27 ++++++- .../imageGeneration/pollinationsAnonAuth.ts | 75 +++++++++++++++++++ ...linations-image-anon-fallback-8085.test.ts | 64 ++++++++++++++++ 4 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/8085-images-anon-fallback.md create mode 100644 open-sse/handlers/imageGeneration/pollinationsAnonAuth.ts create mode 100644 tests/unit/pollinations-image-anon-fallback-8085.test.ts diff --git a/changelog.d/fixes/8085-images-anon-fallback.md b/changelog.d/fixes/8085-images-anon-fallback.md new file mode 100644 index 0000000000..77cd06e296 --- /dev/null +++ b/changelog.d/fixes/8085-images-anon-fallback.md @@ -0,0 +1 @@ +- fix(sse): give keyless Pollinations image requests the same anonymous fingerprint-pool fallback the chat path already uses, instead of a real upstream 401 (#8085) diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 452dd12dcb..d58d5363ad 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -69,6 +69,10 @@ import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmin import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts"; import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts"; +import { + applyPollinationsAnonymousFallback, + reportPollinationsAnonOutcome, +} from "./imageGeneration/pollinationsAnonAuth.ts"; interface KieImageOptions { @@ -1031,17 +1035,30 @@ async function handleOpenAIImageGeneration({ } // Build headers - const headers = { + let headers: Record = { "Content-Type": "application/json", }; const token = credentials.apiKey || credentials.accessToken; - if (providerConfig.authHeader === "bearer") { + if (token && providerConfig.authHeader === "bearer") { headers["Authorization"] = `Bearer ${token}`; - } else if (providerConfig.authHeader === "x-api-key") { + } else if (token && providerConfig.authHeader === "x-api-key") { headers["x-api-key"] = token; } + // #8085 — keyless Pollinations image requests (the common free case) get + // no Authorization header above. Mirror the chat executor's anonymous + // fingerprint-pool fallback (open-sse/executors/pollinations.ts) so the + // outbound request isn't sent bare and rejected by Pollinations' own 401. + let pollinationsAnonSession: Awaited< + ReturnType + >["session"] = null; + if (providerConfig.id === "pollinations") { + const anon = await applyPollinationsAnonymousFallback(providerConfig.id, token, headers); + headers = anon.headers; + pollinationsAnonSession = anon.session; + } + if (log) { const promptPreview = typeof body.prompt === "string" @@ -1082,6 +1099,10 @@ async function handleOpenAIImageGeneration({ ); } + if (pollinationsAnonSession) { + reportPollinationsAnonOutcome(pollinationsAnonSession, result.status); + } + // Save call log after result is determined saveCallLog({ method: "POST", diff --git a/open-sse/handlers/imageGeneration/pollinationsAnonAuth.ts b/open-sse/handlers/imageGeneration/pollinationsAnonAuth.ts new file mode 100644 index 0000000000..c7c5adc68d --- /dev/null +++ b/open-sse/handlers/imageGeneration/pollinationsAnonAuth.ts @@ -0,0 +1,75 @@ +// #8085 — anonymous fingerprint fallback for keyless Pollinations image requests. +// +// Chat requests to Pollinations already fall back to a fingerprint-pool +// session when no apiKey/accessToken is configured (see +// PollinationsExecutor.execute()'s `isAnonymous` branch in +// open-sse/executors/pollinations.ts). The image path +// (open-sse/handlers/imageGeneration.ts::handleOpenAIImageGeneration) had no +// equivalent: a keyless Pollinations image request went out with no +// Authorization header AND no fingerprint headers, so Pollinations' own +// upstream legitimately rejected it with a 401 — even with a perfectly +// valid OmniRoute API key. This module mirrors that same anonymous +// session-pool fallback for the image path. + +import { SessionPool } from "../../services/sessionPool/sessionPool.ts"; +import { DEFAULT_POOL_CONFIG } from "../../services/sessionPool/types.ts"; +import type { Session } from "../../services/sessionPool/session.ts"; + +let pollinationsImagePool: SessionPool | null = null; + +function getPollinationsImagePool(): SessionPool { + if (!pollinationsImagePool) { + pollinationsImagePool = new SessionPool("pollinations", DEFAULT_POOL_CONFIG); + pollinationsImagePool.warmUp(DEFAULT_POOL_CONFIG.minSessions).catch(() => {}); + } + return pollinationsImagePool; +} + +/** + * When `providerId` is Pollinations and no real key/token is present, acquire + * a fingerprint-pool session and return its headers merged over `headers`, + * plus the session so the caller can release it once the upstream call is + * done. No-op (returns `{ headers, session: null }`) for every other + * provider or when a real key is configured. + */ +export async function applyPollinationsAnonymousFallback( + providerId: string, + token: string | undefined, + headers: Record +): Promise<{ headers: Record; session: Session | null }> { + if (providerId !== "pollinations" || token) { + return { headers, session: null }; + } + + const pool = getPollinationsImagePool(); + let session: Session | null = null; + try { + session = await pool.acquireBlocking(10_000); + } catch { + // Pool exhausted — fall through without fingerprint headers rather than + // block the request indefinitely. + session = null; + } + + if (!session) { + return { headers, session: null }; + } + + return { + headers: { ...headers, ...session.buildHeaders() }, + session, + }; +} + +/** Report the outcome of an anonymous Pollinations image request back to the pool. */ +export function reportPollinationsAnonOutcome(session: Session | null, status: number | undefined): void { + if (!session || !pollinationsImagePool) return; + if (status === 429) { + pollinationsImagePool.reportCooldown(session); + } else if (typeof status === "number" && status >= 500) { + pollinationsImagePool.reportDead(session); + } else { + pollinationsImagePool.reportSuccess(session); + } + session.release(); +} diff --git a/tests/unit/pollinations-image-anon-fallback-8085.test.ts b/tests/unit/pollinations-image-anon-fallback-8085.test.ts new file mode 100644 index 0000000000..3fd739bd1d --- /dev/null +++ b/tests/unit/pollinations-image-anon-fallback-8085.test.ts @@ -0,0 +1,64 @@ +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"; + +// Isolated DATA_DIR so this test never touches the real ~/.omniroute DB +// (handleImageGeneration's call-log path opens the shared DB singleton). +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-images-anon-8085-")); + +// #8085 — Pollinations image-generation requests with NO configured API key +// (the common free/keyless case) must reuse the same anonymous fingerprint +// session-pool fallback that PollinationsExecutor.execute() already applies +// to the chat path (open-sse/executors/pollinations.ts:52-71). Without it, +// the outbound request to gen.pollinations.ai carries no Authorization AND +// no browser fingerprint, so Pollinations legitimately rejects it with a +// real upstream 401 — even though the caller supplied a perfectly valid +// OmniRoute API key. +const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); + +test("#8085 keyless Pollinations image request includes anonymous fingerprint headers (User-Agent) instead of going out bare", async () => { + const originalFetch = globalThis.fetch; + let captured; + + globalThis.fetch = async (url, options = {}) => { + captured = { + url: String(url), + headers: options.headers || {}, + }; + + return new Response( + JSON.stringify({ created: 123, data: [{ url: "https://cdn.example.com/image.png" }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "pollinations/flux", + prompt: "a cat", + }, + // No apiKey/accessToken — the common keyless/free Pollinations case. + credentials: {}, + log: null, + }); + + assert.equal(result.success, true); + assert.ok(captured, "fetch should have been called"); + assert.equal(captured.url, "https://gen.pollinations.ai/v1/images/generations"); + + // No key was supplied, so no Authorization header is expected — but the + // anonymous fallback must inject fingerprint headers (mirroring the chat + // executor's isAnonymous branch) so the upstream doesn't see a bare, + // headerless request and reject it with a real 401. + assert.equal(captured.headers.Authorization, undefined); + assert.ok( + captured.headers["User-Agent"], + "expected anonymous session-pool fingerprint headers (User-Agent) on the outbound Pollinations image request" + ); + } finally { + globalThis.fetch = originalFetch; + } +});