diff --git a/README.md b/README.md index 8fba584..b269785 100644 --- a/README.md +++ b/README.md @@ -33,18 +33,27 @@ make sure the contents of `dist/` are deployed. ## Configuring the Pollinations token -Pollinations models that require tiered access need a token on every request. The application now -expects the token to be provided at runtime so it is never bundled into the static assets. +Pollinations models that require tiered access expect the token to be supplied as a request +parameter. The demo can resolve the token at runtime (via URL parameters, meta tags, or injected +globals) and also honours build-time environment variables when you want to bake the token into the +bundle. - **GitHub Pages / production** – Provide the `POLLI_TOKEN` secret in the repository (or Pages - environment). The included Pages Function at `.github/functions/polli-token.js` exposes the token - at runtime via `/api/polli-token`, and responses are marked as non-cacheable. -- **Local development** – Either define `POLLI_TOKEN`/`VITE_POLLI_TOKEN` in your shell when running - `npm run dev`, add a `` tag to `index.html`, or inject - `window.__POLLINATIONS_TOKEN__` before the application bootstraps. -- **Static overrides** – When a dynamic endpoint is unavailable, append a `token` query parameter - to the page URL (e.g. `https://example.github.io/chatdemo/?token=your-secret`). The application - will capture the token, remove it from the visible URL, and apply it to subsequent Pollinations - requests. - -If the token cannot be resolved the UI remains disabled and an error is shown in the status banner. + environment). You can surface the token to the client by setting `window.__POLLINATIONS_TOKEN__`, + defining a `` tag, adding a `token=...` query + parameter to the published URL (e.g. `https://example.github.io/chatdemo/?token=your-secret`), or + injecting `POLLI_TOKEN`/`VITE_POLLI_TOKEN` during the build so the token ships with the bundle. + The token is removed from the visible URL after it is captured. +- **Local development** – Define `POLLI_TOKEN`/`VITE_POLLI_TOKEN` in your shell when running + `npm run dev`, add a meta tag as above, or inject `window.__POLLINATIONS_TOKEN__` before the + application bootstraps. Build-time environment variables also work in development. +- **Optional runtime endpoint** – If you expose the token via a custom endpoint, configure its URL + with `POLLI_TOKEN_ENDPOINT`/`VITE_POLLI_TOKEN_ENDPOINT` (environment variables), + `window.__POLLINATIONS_TOKEN_ENDPOINT__`, or a `` tag. + When present, the client will fetch the token from that endpoint. + +If the token cannot be resolved the application continues without one, allowing you to browse public +models while gated Pollinations models remain unavailable until a token is supplied. + +All chat and image requests automatically include a random eight-digit `seed` parameter so they +match Pollinations' expected request format. diff --git a/src/main.js b/src/main.js index 5b2a636..01cf6b9 100644 --- a/src/main.js +++ b/src/main.js @@ -6,6 +6,7 @@ import { matchesModelIdentifier, normalizeTextCatalog, } from './model-catalog.js'; +import { generateSeed } from './seed.js'; const FALLBACK_MODELS = [ createFallbackModel('openai', 'OpenAI GPT-5 Nano (fallback)'), @@ -443,6 +444,7 @@ async function handleChatResponse(initialResponse, model, endpoint) { messages: state.conversation, tools: [IMAGE_TOOL], tool_choice: 'auto', + seed: generateSeed(), }, client, ); @@ -499,7 +501,7 @@ async function handleToolCalls(toolCalls) { const caption = String(args.caption ?? prompt).trim() || prompt; try { - const { dataUrl } = await generateImageAsset(prompt, { + const { dataUrl, seed } = await generateImageAsset(prompt, { width, height, model: args.model, @@ -520,6 +522,7 @@ async function handleToolCalls(toolCalls) { prompt, width, height, + seed, }), }); } catch (error) { @@ -545,12 +548,14 @@ async function generateImageAsset(prompt, { width, height, model: imageModel } = if (!client) { throw new Error('Pollinations client is not ready.'); } + const seed = generateSeed(); const binary = await image( prompt, { width, height, model: imageModel, + seed, nologo: true, private: true, enhance: true, @@ -559,7 +564,7 @@ async function generateImageAsset(prompt, { width, height, model: imageModel } = ); const dataUrl = binary.toDataUrl(); resetStatusIfIdle(); - return { dataUrl }; + return { dataUrl, seed }; } catch (error) { console.error('Image generation failed', error); throw error; @@ -770,6 +775,7 @@ async function requestChatCompletion(model, endpoints) { const attemptErrors = []; for (const endpoint of endpoints) { try { + const requestSeed = generateSeed(); const response = await chat( { model: model.id, @@ -777,6 +783,7 @@ async function requestChatCompletion(model, endpoints) { messages: state.conversation, tools: [IMAGE_TOOL], tool_choice: 'auto', + seed: requestSeed, }, client, ); @@ -907,11 +914,27 @@ async function initializeApp() { els.voicePlayback.checked = false; } + let tokenSource = null; + let tokenMessages = []; + try { - const { client: polliClient, tokenSource } = await createPollinationsClient(); + const { + client: polliClient, + tokenSource: resolvedTokenSource, + tokenMessages: resolvedTokenMessages, + } = await createPollinationsClient(); client = polliClient; + tokenSource = resolvedTokenSource; + tokenMessages = Array.isArray(resolvedTokenMessages) ? resolvedTokenMessages : []; if (tokenSource) { console.info('Pollinations token loaded via %s.', tokenSource); + } else if (tokenMessages.length) { + console.warn( + 'Proceeding without a Pollinations token. Attempts: %s', + tokenMessages.join('; '), + ); + } else { + console.info('Proceeding without a Pollinations token.'); } } catch (error) { console.error('Failed to configure Pollinations client', error); @@ -928,6 +951,10 @@ async function initializeApp() { setLoading(false); } + if (!tokenSource && !state.statusError) { + setStatus('Ready. Pollinations token not configured; only public models are available.'); + } + try { setupRecognition(); } catch (error) { @@ -955,7 +982,7 @@ els.form.addEventListener('submit', async event => { if (!prompt) { throw new Error('Provide a prompt after /image'); } - const { dataUrl } = await generateImageAsset(prompt); + const { dataUrl, seed } = await generateImageAsset(prompt); addMessage({ role: 'assistant', type: 'image', @@ -963,6 +990,7 @@ els.form.addEventListener('submit', async event => { alt: prompt, caption: prompt, }); + console.info('Generated Pollinations image with seed %s.', seed); resetStatusIfIdle(); } else { await sendPrompt(raw); diff --git a/src/pollinations-client.js b/src/pollinations-client.js index ea4db81..d3dce8c 100644 --- a/src/pollinations-client.js +++ b/src/pollinations-client.js @@ -1,34 +1,49 @@ import { PolliClient } from '../Libs/pollilib/index.js'; let tokenPromise = null; -let cachedToken = null; -let cachedSource = null; +let cachedResult = null; export async function createPollinationsClient({ referrer } = {}) { - const { token, source } = await ensureToken(); - const getToken = async () => token; - const client = new PolliClient({ - auth: { + const tokenResult = await ensureToken(); + const { token, source, messages = [], errors = [] } = tokenResult; + const inferredReferrer = referrer ?? inferReferrer(); + + const clientOptions = {}; + if (token) { + clientOptions.auth = { mode: 'token', placement: 'query', - getToken, - referrer: referrer ?? inferReferrer(), - }, - }); - return { client, tokenSource: source }; + getToken: async () => token, + referrer: inferredReferrer ?? undefined, + }; + } else if (inferredReferrer) { + clientOptions.referrer = inferredReferrer; + } + + const client = new PolliClient(clientOptions); + return { + client, + tokenSource: token ? source : null, + tokenMessages: messages, + tokenErrors: errors, + }; } async function ensureToken() { - if (cachedToken) { - return { token: cachedToken, source: cachedSource }; + if (cachedResult) { + return cachedResult; } if (!tokenPromise) { - tokenPromise = resolveToken(); - } - const result = await tokenPromise; - cachedToken = result.token; - cachedSource = result.source; - return result; + tokenPromise = resolveToken() + .then(result => { + cachedResult = result; + return result; + }) + .finally(() => { + tokenPromise = null; + }); + } + return tokenPromise; } async function resolveToken() { @@ -45,9 +60,14 @@ async function resolveToken() { try { const result = await attempt(); if (result?.token) { + const messages = errors + .map(entry => formatError(entry.source, entry.error)) + .filter(Boolean); return { token: result.token, source: result.source ?? attempt.name ?? 'unknown', + errors, + messages, }; } if (result?.error) { @@ -61,21 +81,24 @@ async function resolveToken() { const messages = errors .map(entry => formatError(entry.source, entry.error)) .filter(Boolean); - const message = - messages.length > 0 - ? `Unable to load Pollinations token. Attempts: ${messages.join('; ')}` - : 'Unable to load Pollinations token.'; - const failure = new Error(message); - failure.causes = errors; - throw failure; + return { + token: null, + source: null, + errors, + messages, + }; } async function fetchTokenFromApi() { + const endpoint = resolveTokenEndpoint(); + if (!endpoint) { + return { token: null, source: 'api' }; + } if (typeof fetch !== 'function') { return { token: null, source: 'api', error: new Error('Fetch is unavailable in this environment.') }; } try { - const response = await fetch('/api/polli-token', { + const response = await fetch(endpoint, { method: 'GET', headers: { Accept: 'application/json' }, cache: 'no-store', @@ -110,7 +133,7 @@ async function fetchTokenFromApi() { function readTokenFromUrl() { const location = getCurrentLocation(); if (!location) { - return { token: null, source: 'url', error: new Error('Location is unavailable.') }; + return { token: null, source: 'url' }; } const { url, searchParams, hashParams, rawFragments } = parseLocation(location); @@ -143,7 +166,7 @@ function readTokenFromUrl() { function readTokenFromMeta() { if (typeof document === 'undefined') { - return { token: null, source: 'meta', error: new Error('Document is unavailable.') }; + return { token: null, source: 'meta' }; } const meta = document.querySelector('meta[name="pollinations-token"]'); if (!meta) { @@ -164,7 +187,7 @@ function readTokenFromMeta() { function readTokenFromWindow() { if (typeof window === 'undefined') { - return { token: null, source: 'window', error: new Error('Window is unavailable.') }; + return { token: null, source: 'window' }; } const candidate = window.__POLLINATIONS_TOKEN__ ?? window.POLLI_TOKEN ?? null; const token = extractTokenValue(candidate); @@ -188,26 +211,32 @@ function readTokenFromEnv() { const importMetaEnv = typeof import.meta !== 'undefined' ? import.meta.env ?? undefined : undefined; const processEnv = typeof process !== 'undefined' && process?.env ? process.env : undefined; - const isDev = determineDevelopmentEnvironment(importMetaEnv, processEnv); - if (!isDev) { - return { token: null, source: 'env' }; + const sources = []; + if (importMetaEnv) { + sources.push([ + importMetaEnv.VITE_POLLI_TOKEN, + importMetaEnv.POLLI_TOKEN, + importMetaEnv.VITE_POLLINATIONS_TOKEN, + importMetaEnv.POLLINATIONS_TOKEN, + ]); + } + if (processEnv) { + sources.push([ + processEnv.VITE_POLLI_TOKEN, + processEnv.POLLI_TOKEN, + processEnv.VITE_POLLINATIONS_TOKEN, + processEnv.POLLINATIONS_TOKEN, + ]); } - const token = extractTokenValue([ - importMetaEnv?.VITE_POLLI_TOKEN, - importMetaEnv?.POLLI_TOKEN, - importMetaEnv?.VITE_POLLINATIONS_TOKEN, - importMetaEnv?.POLLINATIONS_TOKEN, - processEnv?.VITE_POLLI_TOKEN, - processEnv?.POLLI_TOKEN, - processEnv?.VITE_POLLINATIONS_TOKEN, - processEnv?.POLLINATIONS_TOKEN, - ]); - - if (!token) { - return { token: null, source: 'env' }; + for (const group of sources) { + const token = extractTokenValue(group); + if (token) { + return { token, source: 'env' }; + } } - return { token, source: 'env' }; + + return { token: null, source: 'env' }; } function getCurrentLocation() { @@ -360,19 +389,49 @@ function sanitizeUrlToken(location, url, tokenKeys) { } } -function determineDevelopmentEnvironment(importMetaEnv, processEnv) { - if (importMetaEnv && typeof importMetaEnv.DEV !== 'undefined') { - return !!importMetaEnv.DEV; - } - if (processEnv) { - if (typeof processEnv.VITE_DEV_SERVER_URL !== 'undefined') { - return true; - } - if (typeof processEnv.NODE_ENV !== 'undefined') { - return processEnv.NODE_ENV !== 'production'; +function resolveTokenEndpoint() { + const importMetaEnv = typeof import.meta !== 'undefined' ? import.meta.env ?? undefined : undefined; + const processEnv = typeof process !== 'undefined' && process?.env ? process.env : undefined; + const envCandidates = [ + importMetaEnv?.VITE_POLLI_TOKEN_ENDPOINT, + importMetaEnv?.POLLI_TOKEN_ENDPOINT, + importMetaEnv?.VITE_POLLINATIONS_TOKEN_ENDPOINT, + importMetaEnv?.POLLINATIONS_TOKEN_ENDPOINT, + processEnv?.VITE_POLLI_TOKEN_ENDPOINT, + processEnv?.POLLI_TOKEN_ENDPOINT, + processEnv?.VITE_POLLINATIONS_TOKEN_ENDPOINT, + processEnv?.POLLINATIONS_TOKEN_ENDPOINT, + ]; + + const windowCandidates = []; + if (typeof window !== 'undefined') { + windowCandidates.push( + window.__POLLINATIONS_TOKEN_ENDPOINT__, + window.POLLI_TOKEN_ENDPOINT, + window.POLLINATIONS_TOKEN_ENDPOINT, + ); + } + + const metaCandidates = []; + if (typeof document !== 'undefined' && document?.querySelector) { + const names = ['pollinations-token-endpoint', 'polli-token-endpoint']; + for (const name of names) { + const meta = document.querySelector(`meta[name="${name}"]`); + if (!meta) continue; + const content = meta.getAttribute('content'); + if (typeof content === 'string') { + metaCandidates.push(content); + } } } - return false; + + const candidates = [...envCandidates, ...windowCandidates, ...metaCandidates]; + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue; + const trimmed = candidate.trim(); + if (trimmed) return trimmed; + } + return null; } function extractTokenValue(value) { @@ -380,6 +439,10 @@ function extractTokenValue(value) { if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) return null; + const lower = trimmed.toLowerCase(); + if (lower === 'undefined' || lower === 'null') { + return null; + } if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { try { return extractTokenValue(JSON.parse(trimmed)); @@ -429,11 +492,9 @@ function inferReferrer() { function resetTokenCache() { tokenPromise = null; - cachedToken = null; - cachedSource = null; + cachedResult = null; } export const __testing = { resetTokenCache, - determineDevelopmentEnvironment, }; diff --git a/src/seed.js b/src/seed.js new file mode 100644 index 0000000..f48073f --- /dev/null +++ b/src/seed.js @@ -0,0 +1,10 @@ +const MIN_SEED = 10_000_000; +const MAX_SEED = 99_999_999; + +export function generateSeed(random = Math.random) { + const fn = typeof random === 'function' ? random : Math.random; + const value = fn(); + const clamped = Number.isFinite(value) ? Math.max(0, Math.min(0.999999999999, value)) : 0; + const span = MAX_SEED - MIN_SEED + 1; + return Math.floor(clamped * span + MIN_SEED); +} diff --git a/tests/pollinations-token-env.test.mjs b/tests/pollinations-token-env.test.mjs index 9713e1b..81ae803 100644 --- a/tests/pollinations-token-env.test.mjs +++ b/tests/pollinations-token-env.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { createPollinationsClient, __testing } from '../src/pollinations-client.js'; -export const name = 'Pollinations client resolves tokens from development environment variables'; +export const name = 'Pollinations client resolves tokens from environment variables'; function createStubResponse(status = 404) { return { @@ -24,12 +24,16 @@ function createStubResponse(status = 404) { export async function run() { const originalFetch = globalThis.fetch; const originalToken = process.env.POLLI_TOKEN; + const originalViteToken = process.env.VITE_POLLI_TOKEN; + const originalVitePollinationsToken = process.env.VITE_POLLINATIONS_TOKEN; const originalNodeEnv = process.env.NODE_ENV; try { globalThis.fetch = async () => createStubResponse(404); process.env.POLLI_TOKEN = 'process-env-token'; - process.env.NODE_ENV = 'development'; + process.env.VITE_POLLI_TOKEN = 'undefined'; + process.env.VITE_POLLINATIONS_TOKEN = 'null'; + process.env.NODE_ENV = 'production'; __testing.resetTokenCache(); const { client, tokenSource } = await createPollinationsClient(); @@ -50,10 +54,18 @@ export async function run() { process.env.POLLI_TOKEN = originalToken; } - if (typeof originalNodeEnv === 'undefined') { - delete process.env.NODE_ENV; - } else { - process.env.NODE_ENV = originalNodeEnv; + const envKeys = [ + ['POLLI_TOKEN', originalToken], + ['VITE_POLLI_TOKEN', originalViteToken], + ['VITE_POLLINATIONS_TOKEN', originalVitePollinationsToken], + ['NODE_ENV', originalNodeEnv], + ]; + for (const [key, original] of envKeys) { + if (typeof original === 'undefined') { + delete process.env[key]; + } else { + process.env[key] = original; + } } __testing.resetTokenCache(); diff --git a/tests/pollinations-token-optional.test.mjs b/tests/pollinations-token-optional.test.mjs new file mode 100644 index 0000000..5247335 --- /dev/null +++ b/tests/pollinations-token-optional.test.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { createPollinationsClient, __testing } from '../src/pollinations-client.js'; + +export const name = + 'Pollinations client falls back to unauthenticated access when no token endpoint is configured'; + +function createStubResponse(status = 404) { + return { + status, + ok: status >= 200 && status < 300, + headers: { + get() { + return null; + }, + }, + async json() { + return {}; + }, + async text() { + return ''; + }, + }; +} + +export async function run() { + const originalFetch = globalThis.fetch; + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const originalLocation = globalThis.location; + const originalHistory = globalThis.history; + const originalNodeEnv = process.env.NODE_ENV; + const originalGlobalEndpoints = { + __POLLINATIONS_TOKEN_ENDPOINT__: globalThis.__POLLINATIONS_TOKEN_ENDPOINT__, + POLLI_TOKEN_ENDPOINT: globalThis.POLLI_TOKEN_ENDPOINT, + POLLINATIONS_TOKEN_ENDPOINT: globalThis.POLLINATIONS_TOKEN_ENDPOINT, + }; + const tokenEnvKeys = [ + 'POLLI_TOKEN', + 'VITE_POLLI_TOKEN', + 'POLLINATIONS_TOKEN', + 'VITE_POLLINATIONS_TOKEN', + ]; + const endpointEnvKeys = [ + 'POLLI_TOKEN_ENDPOINT', + 'VITE_POLLI_TOKEN_ENDPOINT', + 'POLLINATIONS_TOKEN_ENDPOINT', + 'VITE_POLLINATIONS_TOKEN_ENDPOINT', + ]; + const originalEnv = Object.fromEntries( + [...tokenEnvKeys, ...endpointEnvKeys].map(key => [key, process.env[key]]), + ); + + try { + let fetchCalled = 0; + const fetchUrls = []; + globalThis.fetch = async (...args) => { + fetchCalled += 1; + fetchUrls.push(args[0]); + return createStubResponse(404); + }; + delete globalThis.window; + delete globalThis.document; + delete globalThis.location; + delete globalThis.history; + for (const key of tokenEnvKeys) { + delete process.env[key]; + } + for (const key of endpointEnvKeys) { + delete process.env[key]; + } + process.env.POLLI_TOKEN = 'undefined'; + process.env.VITE_POLLI_TOKEN = 'null'; + delete process.env.NODE_ENV; + delete globalThis.__POLLINATIONS_TOKEN_ENDPOINT__; + delete globalThis.POLLI_TOKEN_ENDPOINT; + delete globalThis.POLLINATIONS_TOKEN_ENDPOINT; + + __testing.resetTokenCache(); + + const { client, tokenSource, tokenMessages } = await createPollinationsClient(); + + assert.equal(tokenSource, null); + assert.equal(client.authMode, 'none'); + assert.ok(Array.isArray(tokenMessages)); + assert.equal(tokenMessages.length, 0, `Unexpected messages: ${tokenMessages.join('; ')}`); + if (fetchCalled !== 0) { + throw new Error(`Unexpected token fetch attempts: ${fetchUrls.join(', ')}`); + } + } finally { + if (originalFetch) { + globalThis.fetch = originalFetch; + } else { + delete globalThis.fetch; + } + + if (typeof originalWindow === 'undefined') { + delete globalThis.window; + } else { + globalThis.window = originalWindow; + } + + if (typeof originalDocument === 'undefined') { + delete globalThis.document; + } else { + globalThis.document = originalDocument; + } + + if (typeof originalLocation === 'undefined') { + delete globalThis.location; + } else { + globalThis.location = originalLocation; + } + + if (typeof originalHistory === 'undefined') { + delete globalThis.history; + } else { + globalThis.history = originalHistory; + } + + for (const key of [...tokenEnvKeys, ...endpointEnvKeys]) { + if (typeof originalEnv[key] === 'undefined') { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + + if (typeof originalNodeEnv === 'undefined') { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + + if (typeof originalGlobalEndpoints.__POLLINATIONS_TOKEN_ENDPOINT__ === 'undefined') { + delete globalThis.__POLLINATIONS_TOKEN_ENDPOINT__; + } else { + globalThis.__POLLINATIONS_TOKEN_ENDPOINT__ = + originalGlobalEndpoints.__POLLINATIONS_TOKEN_ENDPOINT__; + } + if (typeof originalGlobalEndpoints.POLLI_TOKEN_ENDPOINT === 'undefined') { + delete globalThis.POLLI_TOKEN_ENDPOINT; + } else { + globalThis.POLLI_TOKEN_ENDPOINT = originalGlobalEndpoints.POLLI_TOKEN_ENDPOINT; + } + if (typeof originalGlobalEndpoints.POLLINATIONS_TOKEN_ENDPOINT === 'undefined') { + delete globalThis.POLLINATIONS_TOKEN_ENDPOINT; + } else { + globalThis.POLLINATIONS_TOKEN_ENDPOINT = + originalGlobalEndpoints.POLLINATIONS_TOKEN_ENDPOINT; + } + + __testing.resetTokenCache(); + } +} diff --git a/tests/seed-generator.test.mjs b/tests/seed-generator.test.mjs new file mode 100644 index 0000000..139bc96 --- /dev/null +++ b/tests/seed-generator.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { generateSeed } from '../src/seed.js'; + +export const name = 'Seed generator produces eight-digit integers'; + +export async function run() { + const minimum = generateSeed(() => 0); + const maximum = generateSeed(() => 0.999999999999); + const clampHigh = generateSeed(() => 1.5); + const clampLow = generateSeed(() => -0.5); + const mid = generateSeed(() => 0.42); + + for (const value of [minimum, maximum, clampHigh, clampLow, mid]) { + assert(Number.isInteger(value), `Seed should be an integer (received ${value})`); + assert(value >= 10_000_000 && value <= 99_999_999, `Seed ${value} must be eight digits`); + assert.equal(String(value).length, 8, `Seed ${value} should contain exactly eight digits`); + } + + assert.equal(minimum, 10_000_000); + assert.equal(maximum, 99_999_999); + assert.equal(clampHigh, 99_999_999); + assert.equal(clampLow, 10_000_000); +}