From 881b72a25faf13c9bf3771921a0690cbaad9a33c Mon Sep 17 00:00:00 2001 From: Hackall <36754621+hackall360@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:20:25 -0700 Subject: [PATCH 1/2] Use /openai payloads for seed chat requests --- Libs/pollilib/src/text.js | 171 +--------------------------- src/main.js | 16 +-- tests/pollilib-chat.test.mjs | 13 ++- tests/pollilib-seed-chat.test.mjs | 41 ++++--- tests/pollilib-token-query.test.mjs | 26 ++++- 5 files changed, 64 insertions(+), 203 deletions(-) diff --git a/Libs/pollilib/src/text.js b/Libs/pollilib/src/text.js index 0bb2f47..ec119db 100644 --- a/Libs/pollilib/src/text.js +++ b/Libs/pollilib/src/text.js @@ -81,27 +81,11 @@ export async function chat({ throw new Error('chat() requires a non-empty messages array'); } const targetEndpoint = resolveChatEndpoint(endpoint); - if (targetEndpoint === 'seed') { - return await performSeedChat( - { - model, - messages, - seed, - temperature, - top_p, - presence_penalty, - frequency_penalty, - max_tokens, - private: priv, - response_format, - timeoutMs, - stream, - }, - client, - ); - } - const url = `${client.textBase}/${encodeURIComponent(targetEndpoint)}`; + const url = `${client.textBase}/openai`; const body = { model, messages }; + if (targetEndpoint && targetEndpoint !== 'openai') { + body.endpoint = targetEndpoint; + } if (seed != null) body.seed = seed; if (temperature != null) body.temperature = temperature; if (top_p != null) body.top_p = top_p; @@ -161,150 +145,3 @@ function resolveChatEndpoint(endpoint) { value = value.replace(/^\/+/u, '').replace(/\/+$/u, '').toLowerCase(); return value || 'openai'; } - -async function performSeedChat( - { - model, - messages, - seed, - temperature, - top_p, - presence_penalty, - frequency_penalty, - max_tokens, - private: priv, - response_format, - timeoutMs, - stream, - }, - client, -) { - if (stream) { - throw new Error('Seed endpoint currently does not support streaming responses.'); - } - const prompt = buildSeedPrompt(messages); - const url = `${client.textBase}/${encodeURIComponent(prompt)}`; - const params = {}; - if (model) params.model = model; - if (seed != null) params.seed = seed; - if (temperature != null) params.temperature = temperature; - if (top_p != null) params.top_p = top_p; - if (presence_penalty != null) params.presence_penalty = presence_penalty; - if (frequency_penalty != null) params.frequency_penalty = frequency_penalty; - if (max_tokens != null) params.max_tokens = max_tokens; - if (priv != null) params.private = boolString(priv); - - let expectJson = false; - if (response_format) { - if (response_format === 'json_object') { - expectJson = true; - } else if ( - typeof response_format === 'object' && - response_format !== null && - response_format.type === 'json_object' - ) { - expectJson = true; - } - } - if (expectJson) params.json = 'true'; - - const response = await client.get(url, { params, timeoutMs }); - await raiseForStatus(response, 'chat(seed)'); - let bodyText = await response.text(); - if (!expectJson) { - bodyText = bodyText ?? ''; - } - - const created = Math.floor(Date.now() / 1000); - const completionId = `pllns_${created.toString(36)}${Math.random().toString(36).slice(2)}`; - return { - id: completionId, - object: 'chat.completion', - created, - model: model ?? 'seed', - choices: [ - { - index: 0, - finish_reason: 'stop', - message: { - role: 'assistant', - content: expectJson ? bodyText : String(bodyText ?? ''), - }, - }, - ], - }; -} - -function buildSeedPrompt(messages) { - const safeMessages = Array.isArray(messages) ? messages : []; - if (!safeMessages.length) { - throw new Error('chat(seed) requires at least one message.'); - } - const lines = []; - for (const message of safeMessages) { - const roleLabel = describeRole(message?.role, message?.name); - const parts = []; - const content = extractChatContent(message?.content); - if (content) parts.push(content); - if (Array.isArray(message?.tool_calls) && message.tool_calls.length) { - for (const call of message.tool_calls) { - const description = formatToolCall(call); - if (description) parts.push(description); - } - } - lines.push(parts.length ? `${roleLabel}: ${parts.join('\n')}` : `${roleLabel}:`); - } - lines.push('Assistant:'); - return lines.join('\n\n'); -} - -function describeRole(role, name) { - if (!role) return 'Message'; - const normalized = String(role).trim().toLowerCase(); - switch (normalized) { - case 'system': - return 'System'; - case 'user': - return name ? `User (${name})` : 'User'; - case 'assistant': - return 'Assistant'; - case 'tool': - return name ? `Tool (${name})` : 'Tool'; - default: - return normalized ? normalized[0].toUpperCase() + normalized.slice(1) : 'Message'; - } -} - -function extractChatContent(content) { - if (content == null) return ''; - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content - .map(entry => { - if (!entry) return ''; - if (typeof entry === 'string') return entry; - if (typeof entry === 'object') { - if (entry.text != null) return String(entry.text); - if (entry.content != null) return String(entry.content); - if (entry.type === 'text' && entry.value != null) return String(entry.value); - } - return ''; - }) - .filter(Boolean) - .join('\n'); - } - if (typeof content === 'object') { - if (content.text != null) return String(content.text); - if (content.content != null) return String(content.content); - } - return String(content); -} - -function formatToolCall(call) { - if (!call) return ''; - try { - return `Tool call: ${JSON.stringify(call)}`; - } catch { - return 'Tool call: [unserializable]'; - } -} diff --git a/src/main.js b/src/main.js index 01cf6b9..48db467 100644 --- a/src/main.js +++ b/src/main.js @@ -62,20 +62,8 @@ const IMAGE_TOOL = { }, }; -const SYSTEM_PROMPT = ` -You are a helpful assistant for Pollinations chats that can see the full conversation. -When a user asks for an illustration—or when a visual would help—call the -"generate_image" tool with a vivid prompt and any desired dimensions. After the tool -runs, briefly describe what you created. Otherwise, reply conversationally. -Keep responses concise, friendly, and helpful. -`; - let client = null; -function createSystemMessage() { - return { role: 'system', content: SYSTEM_PROMPT }; -} - const app = document.querySelector('#app'); app.innerHTML = `
@@ -138,7 +126,7 @@ if (els.voicePlayback) { } const state = { - conversation: [createSystemMessage()], + conversation: [], messages: [], loading: false, models: [], @@ -216,7 +204,7 @@ function addMessage(message) { } function resetConversation({ clearMessages = false } = {}) { - state.conversation = [createSystemMessage()]; + state.conversation = []; state.activeModel = null; if (clearMessages) { state.messages = []; diff --git a/tests/pollilib-chat.test.mjs b/tests/pollilib-chat.test.mjs index 25bffd7..e392c20 100644 --- a/tests/pollilib-chat.test.mjs +++ b/tests/pollilib-chat.test.mjs @@ -26,7 +26,8 @@ export async function run() { requests.push(entry); const method = entry.init.method ?? 'GET'; if (method === 'POST') { - const model = entry.url.endsWith('/openai') ? 'openai' : 'unknown'; + const body = entry.init.body ? JSON.parse(entry.init.body) : {}; + const model = body.model ?? 'unknown'; return new Response(createResponseBody(model), { status: 200, headers: { 'Content-Type': 'application/json' }, @@ -50,8 +51,10 @@ export async function run() { const seedResponse = await chat({ model: 'unity', endpoint: 'seed', messages }, client); assert.equal(seedResponse.model, 'unity'); assert.equal(seedResponse.choices[0].message.content, 'Hello from Pollinations!'); - assert.equal(requests[1].init.method, 'GET'); - const seedUrl = new URL(requests[1].url); - assert.equal(seedUrl.searchParams.get('model'), 'unity'); - assert(seedUrl.pathname.length > 1, 'Seed request should encode the prompt in the path'); + assert.equal(requests[1].init.method, 'POST'); + assert.ok(requests[1].url.endsWith('/openai')); + const parsedSeedBody = JSON.parse(requests[1].init.body); + assert.equal(parsedSeedBody.model, 'unity'); + assert.equal(parsedSeedBody.endpoint, 'seed'); + assert.deepEqual(parsedSeedBody.messages, messages); } diff --git a/tests/pollilib-seed-chat.test.mjs b/tests/pollilib-seed-chat.test.mjs index 02c8107..05b4907 100644 --- a/tests/pollilib-seed-chat.test.mjs +++ b/tests/pollilib-seed-chat.test.mjs @@ -1,17 +1,33 @@ import assert from 'node:assert/strict'; import { PolliClient, chat } from '../Libs/pollilib/index.js'; -export const name = 'PolliLib chat() flattens conversations for the seed endpoint'; +export const name = 'PolliLib chat() posts payloads to /openai for the seed endpoint'; export async function run() { const requests = []; const fakeFetch = async (url, init) => { const entry = { url: String(url), init: { ...(init ?? {}) } }; requests.push(entry); - return new Response('Unity says hi!', { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }); + const body = entry.init.body ? JSON.parse(entry.init.body) : {}; + return new Response( + JSON.stringify({ + id: 'chatcmpl-seed', + object: 'chat.completion', + created: Date.now(), + model: body.model ?? 'unknown', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Unity says hi!' }, + finish_reason: 'stop', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); }; const client = new PolliClient({ fetch: fakeFetch, textBase: 'https://text.pollinations.ai' }); @@ -29,14 +45,13 @@ export async function run() { assert.equal(requests.length, 1, 'Expected exactly one seed request'); const request = requests[0]; - assert.equal(request.init.method, 'GET'); + assert.equal(request.init.method, 'POST'); const url = new URL(request.url); - assert.equal(url.searchParams.get('model'), 'unity'); - assert.equal(url.searchParams.get('temperature'), '0.2'); - - const prompt = decodeURIComponent(url.pathname.slice(1)); - assert(prompt.includes('System: You are a helpful assistant.'), 'Prompt should include the system message'); - assert(prompt.includes('User: Hello there!'), 'Prompt should include the user content'); - assert(prompt.trim().endsWith('Assistant:'), 'Prompt should end with an assistant cue'); + assert.ok(url.pathname.endsWith('/openai'), 'Seed requests should hit the /openai endpoint'); + const payload = JSON.parse(request.init.body); + assert.equal(payload.model, 'unity'); + assert.equal(payload.temperature, 0.2); + assert.equal(payload.endpoint, 'seed'); + assert.deepEqual(payload.messages, messages); } diff --git a/tests/pollilib-token-query.test.mjs b/tests/pollilib-token-query.test.mjs index 0494baa..0940484 100644 --- a/tests/pollilib-token-query.test.mjs +++ b/tests/pollilib-token-query.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { PolliClient, chat } from '../Libs/pollilib/index.js'; -export const name = 'PolliLib seed chat requests include query tokens'; +export const name = 'PolliLib seed chat payloads include query tokens'; function createResponse(body) { if (typeof Response === 'function') { @@ -23,8 +23,22 @@ export async function run() { const fakeFetch = async (url, init = {}) => { requests.push({ url: String(url), init: { ...init } }); const method = init.method ?? 'GET'; - if (method === 'GET') { - return createResponse('Unity says hi!'); + if (method === 'POST') { + return createResponse( + JSON.stringify({ + id: 'chatcmpl-token', + object: 'chat.completion', + created: Date.now(), + model: 'unity', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Unity says hi!' }, + finish_reason: 'stop', + }, + ], + }), + ); } return createResponse(''); }; @@ -42,9 +56,13 @@ export async function run() { assert.equal(result.choices?.[0]?.message?.content, 'Unity says hi!'); assert.equal(requests.length, 1, 'Expected a single request to be issued.'); const [request] = requests; - assert.equal(request.init.method, 'GET'); + assert.equal(request.init.method, 'POST'); const requestUrl = new URL(request.url); + assert.ok(requestUrl.pathname.endsWith('/openai')); assert.equal(requestUrl.searchParams.get('token'), 'example-token'); const authHeader = request.init.headers?.Authorization ?? request.init.headers?.authorization; assert.equal(authHeader, 'Bearer example-token'); + const payload = JSON.parse(request.init.body); + assert.equal(payload.endpoint, 'seed'); + assert.deepEqual(payload.messages, messages); } From 1e754a7b22f81b77e78319def91567fb88115dba Mon Sep 17 00:00:00 2001 From: Hackall <36754621+hackall360@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:29:38 -0700 Subject: [PATCH 2/2] Broaden chat payload support --- Libs/pollilib/src/text.js | 126 +++++++++++++++++++++++++++-------- tests/pollilib-chat.test.mjs | 37 +++++++++- 2 files changed, 133 insertions(+), 30 deletions(-) diff --git a/Libs/pollilib/src/text.js b/Libs/pollilib/src/text.js index ec119db..ff178fd 100644 --- a/Libs/pollilib/src/text.js +++ b/Libs/pollilib/src/text.js @@ -59,23 +59,19 @@ export async function text(prompt, options = {}, client = getDefaultClient()) { return await response.text(); } -export async function chat({ - model, - messages, - seed, - temperature, - top_p, - presence_penalty, - frequency_penalty, - max_tokens, - stream, - private: priv, - tools, - tool_choice, - response_format, - timeoutMs, - endpoint, -} = {}, client = getDefaultClient()) { +export async function chat(options = {}, client = getDefaultClient()) { + const { + model, + messages, + stream, + endpoint, + timeoutMs, + private: priv, + jsonMode, + json, + response_format, + ...rest + } = options ?? {}; if (!model) throw new Error('chat() requires a model'); if (!Array.isArray(messages) || !messages.length) { throw new Error('chat() requires a non-empty messages array'); @@ -83,19 +79,21 @@ export async function chat({ const targetEndpoint = resolveChatEndpoint(endpoint); const url = `${client.textBase}/openai`; const body = { model, messages }; + if (priv != null) body.private = !!priv; + const { responseFormat, legacyJson } = resolveResponseFormat({ response_format, jsonMode, json }); + if (responseFormat !== undefined) { + body.response_format = responseFormat; + } + if (legacyJson !== undefined) { + body.json = legacyJson; + } + for (const [key, value] of Object.entries(rest)) { + if (value === undefined) continue; + body[key] = value; + } if (targetEndpoint && targetEndpoint !== 'openai') { body.endpoint = targetEndpoint; } - if (seed != null) body.seed = seed; - if (temperature != null) body.temperature = temperature; - if (top_p != null) body.top_p = top_p; - if (presence_penalty != null) body.presence_penalty = presence_penalty; - if (frequency_penalty != null) body.frequency_penalty = frequency_penalty; - if (max_tokens != null) body.max_tokens = max_tokens; - if (priv != null) body.private = !!priv; - if (tools) body.tools = tools; - if (tool_choice) body.tool_choice = tool_choice; - if (response_format) body.response_format = response_format; if (stream) { body.stream = true; @@ -145,3 +143,77 @@ function resolveChatEndpoint(endpoint) { value = value.replace(/^\/+/u, '').replace(/\/+$/u, '').toLowerCase(); return value || 'openai'; } + +function resolveResponseFormat({ response_format, jsonMode, json }) { + const normalized = normalizeResponseFormat(response_format); + if (normalized !== undefined) { + return { responseFormat: normalized, legacyJson: jsonForLegacy(json, normalized) }; + } + if (jsonMode === true) { + return { responseFormat: { type: 'json_object' }, legacyJson: undefined }; + } + const jsonAlias = normalizeJsonAlias(json); + if (jsonAlias.responseFormat !== undefined) { + return jsonAlias; + } + return { responseFormat: undefined, legacyJson: jsonAlias.legacyJson }; +} + +function normalizeResponseFormat(value) { + if (value == null) return undefined; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (trimmed === 'json_object') { + return { type: 'json_object' }; + } + return { type: trimmed }; + } + if (typeof value === 'object') { + return value; + } + return undefined; +} + +function normalizeJsonAlias(value) { + if (value == null) { + return { responseFormat: undefined, legacyJson: undefined }; + } + if (value === true || value === 'true') { + return { responseFormat: { type: 'json_object' }, legacyJson: undefined }; + } + if (value === false) { + return { responseFormat: undefined, legacyJson: undefined }; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return { responseFormat: undefined, legacyJson: undefined }; + } + return { responseFormat: { type: trimmed }, legacyJson: undefined }; + } + if (typeof value === 'object') { + return { responseFormat: value, legacyJson: undefined }; + } + return { responseFormat: undefined, legacyJson: value }; +} + +function jsonForLegacy(value, responseFormat) { + if (value == null) return undefined; + if (value === true || value === 'true') return undefined; + if (!responseFormat) return value; + const responseType = typeof responseFormat === 'object' && responseFormat?.type ? String(responseFormat.type) : null; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (responseType && trimmed.toLowerCase() === responseType.toLowerCase()) return undefined; + return value; + } + if (typeof value === 'object') { + if (value === responseFormat) return undefined; + if (value?.type && responseType && String(value.type).toLowerCase() === responseType.toLowerCase()) { + return undefined; + } + } + return value; +} diff --git a/tests/pollilib-chat.test.mjs b/tests/pollilib-chat.test.mjs index e392c20..2636af1 100644 --- a/tests/pollilib-chat.test.mjs +++ b/tests/pollilib-chat.test.mjs @@ -42,13 +42,41 @@ export async function run() { const client = new PolliClient({ fetch: fakeFetch, textBase: 'https://text.pollinations.ai' }); const messages = [{ role: 'user', content: 'Hi there!' }]; - const defaultResponse = await chat({ model: 'openai', messages }, client); + const defaultResponse = await chat( + { + model: 'openai', + messages, + metadata: { session: 'abc123' }, + user: 'tester-1', + parallel_tool_calls: false, + logit_bias: { 42: -1 }, + jsonMode: true, + }, + client, + ); assert.equal(defaultResponse.model, 'openai'); assert.equal(requests[0].init.method, 'POST'); assert.ok(requests[0].url.endsWith('/openai')); - assert.equal(JSON.parse(requests[0].init.body).model, 'openai'); + const defaultPayload = JSON.parse(requests[0].init.body); + assert.equal(defaultPayload.model, 'openai'); + assert.equal(defaultPayload.endpoint, undefined); + assert.deepEqual(defaultPayload.metadata, { session: 'abc123' }); + assert.equal(defaultPayload.user, 'tester-1'); + assert.equal(defaultPayload.parallel_tool_calls, false); + assert.deepEqual(defaultPayload.logit_bias, { 42: -1 }); + assert.deepEqual(defaultPayload.response_format, { type: 'json_object' }); + assert.equal(defaultPayload.json, undefined); - const seedResponse = await chat({ model: 'unity', endpoint: 'seed', messages }, client); + const seedResponse = await chat( + { + model: 'unity', + endpoint: 'seed', + messages, + json: 'json_object', + reasoning: { effort: 'medium' }, + }, + client, + ); assert.equal(seedResponse.model, 'unity'); assert.equal(seedResponse.choices[0].message.content, 'Hello from Pollinations!'); assert.equal(requests[1].init.method, 'POST'); @@ -56,5 +84,8 @@ export async function run() { const parsedSeedBody = JSON.parse(requests[1].init.body); assert.equal(parsedSeedBody.model, 'unity'); assert.equal(parsedSeedBody.endpoint, 'seed'); + assert.deepEqual(parsedSeedBody.response_format, { type: 'json_object' }); + assert.equal(parsedSeedBody.json, undefined); + assert.deepEqual(parsedSeedBody.reasoning, { effort: 'medium' }); assert.deepEqual(parsedSeedBody.messages, messages); }