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
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-22 11:28:12 -03:00
committed by GitHub
parent c252c9d885
commit e6087c62b2
4 changed files with 164 additions and 3 deletions

View File

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

View File

@@ -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<string, string> = {
"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<typeof applyPollinationsAnonymousFallback>
>["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",

View File

@@ -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<string, string>
): Promise<{ headers: Record<string, string>; 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();
}

View File

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