diff --git a/Libs/pollilib/src/audio.js b/Libs/pollilib/src/audio.js index 795d4e5..331bdfa 100644 --- a/Libs/pollilib/src/audio.js +++ b/Libs/pollilib/src/audio.js @@ -3,45 +3,109 @@ import { BinaryData, arrayBufferFrom, base64FromArrayBuffer } from './binary.js' import { raiseForStatus } from './errors.js'; export async function tts(text, options = {}, client = getDefaultClient()) { - if (typeof text !== 'string' || !text.length) { - throw new Error('tts() expects a non-empty text string'); - } - const { voice, model = 'openai-audio', referrer, timeoutMs } = options; - const url = `${client.textBase}/${encodeURIComponent(text)}`; - const params = { model }; - if (voice) params.voice = voice; - if (referrer) params.referrer = referrer; + const normalizedText = normalizeText(text); + const { timeoutMs, ...rest } = options ?? {}; + const params = buildTtsParams(rest); + const url = `${client.textBase}/${encodeURIComponent(normalizedText)}`; + const response = await client.get(url, { params, timeoutMs }); await raiseForStatus(response, 'tts'); return await BinaryData.fromResponse(response); } -export async function stt({ file, data, arrayBuffer, buffer, path, format, question, model = 'openai-audio', timeoutMs } = {}, client = getDefaultClient()) { - let bytes = null; - if (file) bytes = await arrayBufferFrom(file); - else if (data) bytes = await arrayBufferFrom(data); - else if (arrayBuffer) bytes = await arrayBufferFrom(arrayBuffer); - else if (buffer) bytes = await arrayBufferFrom(buffer); - else if (path) bytes = await readFileArrayBuffer(path); - if (!bytes) throw new Error("stt() requires 'file', 'data', 'arrayBuffer', 'buffer', or 'path'"); +export async function ttsUrl(text, options = {}, client = getDefaultClient()) { + const normalizedText = normalizeText(text); + const params = buildTtsParams(options ?? {}); + const url = `${client.textBase}/${encodeURIComponent(normalizedText)}`; + return await client.getSignedUrl(url, { params, includeToken: true }); +} - let fmt = format ?? guessFormat({ file, path }); - if (!fmt) throw new Error("Audio 'format' is required (e.g., 'mp3' or 'wav')"); +export async function stt(options = {}, client = getDefaultClient()) { + const payload = await buildSttPayload(options); + const response = await client.postJson(`${client.textBase}/openai`, payload, { + timeoutMs: options.timeoutMs, + }); + await raiseForStatus(response, 'stt'); + return await response.json(); +} + +async function buildSttPayload(options = {}) { + const { + file, + data, + arrayBuffer, + buffer, + path, + question, + prompt, + model = 'openai-audio', + format, + language, + temperature, + } = options; + + const bytes = await resolveAudioBytes({ file, data, arrayBuffer, buffer, path }); + const mime = format ?? guessFormat({ file, path, explicit: options.mimeType }); + if (!mime) { + throw new Error("stt() requires an audio format (e.g. 'mp3' or 'wav')"); + } const b64 = base64FromArrayBuffer(bytes); - const payload = { - model, - messages: [{ - role: 'user', - content: [ - { type: 'text', text: question ?? 'Transcribe this audio' }, - { type: 'input_audio', input_audio: { data: b64, format: fmt } }, - ], - }], + const userQuestion = question ?? prompt ?? 'Transcribe this audio'; + + const message = { + role: 'user', + content: [ + { type: 'text', text: userQuestion }, + { type: 'input_audio', input_audio: { data: b64, format: mime } }, + ], }; - const response = await client.postJson(`${client.textBase}/openai`, payload, { timeoutMs }); - await raiseForStatus(response, 'stt'); - return await response.json(); + + const payload = { model, messages: [message] }; + if (language) payload.language = language; + if (temperature != null) payload.temperature = temperature; + + return payload; +} + +async function resolveAudioBytes({ file, data, arrayBuffer, buffer, path }) { + if (file) return await arrayBufferFrom(file); + if (data) return await arrayBufferFrom(data); + if (arrayBuffer) return await arrayBufferFrom(arrayBuffer); + if (buffer) return await arrayBufferFrom(buffer); + if (path) return await readFileArrayBuffer(path); + throw new Error("stt() requires 'file', 'data', 'arrayBuffer', 'buffer', or 'path'"); +} + +function buildTtsParams(options) { + const params = {}; + const extras = { ...options }; + + assignIfPresent(params, 'model', extras.model ?? 'openai-audio'); + delete extras.model; + + assignIfPresent(params, 'voice', extras.voice); + delete extras.voice; + + assignIfPresent(params, 'format', extras.format); + delete extras.format; + + assignIfPresent(params, 'language', extras.language); + delete extras.language; + + if ('referrer' in extras && extras.referrer) { + params.referrer = extras.referrer; + delete extras.referrer; + } + + delete extras.timeoutMs; + + for (const [key, value] of Object.entries(extras)) { + if (value === undefined || value === null) continue; + params[key] = value; + } + + return params; } async function readFileArrayBuffer(path) { @@ -56,7 +120,8 @@ async function readFileArrayBuffer(path) { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } -function guessFormat({ file, path }) { +function guessFormat({ file, path, explicit }) { + if (explicit) return explicit; if (file?.type?.startsWith?.('audio/')) { return file.type.split('/')[1]; } @@ -66,3 +131,20 @@ function guessFormat({ file, path }) { } return null; } + +function normalizeText(text) { + if (typeof text !== 'string') { + throw new Error('tts() expects the text to be a string'); + } + const trimmed = text.trim(); + if (!trimmed) { + throw new Error('tts() requires a non-empty text string'); + } + return trimmed; +} + +function assignIfPresent(target, key, value) { + if (value !== undefined && value !== null && value !== '') { + target[key] = value; + } +} diff --git a/Libs/pollilib/src/binary.js b/Libs/pollilib/src/binary.js index 4f96ec2..9763876 100644 --- a/Libs/pollilib/src/binary.js +++ b/Libs/pollilib/src/binary.js @@ -1,18 +1,29 @@ const hasBuffer = typeof Buffer !== 'undefined' && typeof Buffer.from === 'function'; +const hasBlob = typeof Blob !== 'undefined'; +const hasReadableStream = typeof ReadableStream !== 'undefined'; export class BinaryData { - constructor(arrayBuffer, mimeType = 'application/octet-stream') { - if (!(arrayBuffer instanceof ArrayBuffer)) { + constructor(buffer, mimeType = 'application/octet-stream') { + if (!(buffer instanceof ArrayBuffer)) { throw new TypeError('BinaryData expects an ArrayBuffer'); } - this._buffer = arrayBuffer; + this._buffer = buffer; this.mimeType = mimeType || 'application/octet-stream'; this._view = null; + this._objectUrl = null; } static async fromResponse(response) { - const buffer = await response.arrayBuffer(); + const arrayBuffer = await response.arrayBuffer(); const mimeType = response.headers?.get?.('content-type') ?? undefined; + return new BinaryData(arrayBuffer, mimeType); + } + + static async from(input, mimeType) { + if (input instanceof BinaryData) { + return new BinaryData(input.arrayBuffer(), mimeType ?? input.mimeType); + } + const buffer = await arrayBufferFrom(input); return new BinaryData(buffer, mimeType); } @@ -25,7 +36,7 @@ export class BinaryData { } uint8Array() { - return this._view ??= new Uint8Array(this._buffer); + return (this._view ??= new Uint8Array(this._buffer)); } toBase64() { @@ -37,20 +48,20 @@ export class BinaryData { } blob() { - if (typeof Blob === 'undefined') { - throw new Error('Blob constructor is not available in this environment'); + if (!hasBlob) { + throw new Error('Blob is not available in this environment'); } return new Blob([this._buffer], { type: this.mimeType }); } stream() { - if (typeof ReadableStream === 'undefined') { + if (!hasReadableStream) { throw new Error('ReadableStream is not available in this environment'); } - const bytes = this.uint8Array(); + const chunk = this.uint8Array(); return new ReadableStream({ start(controller) { - controller.enqueue(bytes); + controller.enqueue(chunk); controller.close(); }, }); @@ -62,26 +73,50 @@ export class BinaryData { } return Buffer.from(this._buffer); } + + toObjectUrl() { + if (!hasBlob || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { + throw new Error('Object URLs are not supported in this environment'); + } + if (!this._objectUrl) { + this._objectUrl = URL.createObjectURL(this.blob()); + } + return this._objectUrl; + } + + revokeObjectUrl() { + if (this._objectUrl && typeof URL?.revokeObjectURL === 'function') { + URL.revokeObjectURL(this._objectUrl); + this._objectUrl = null; + } + } } export async function arrayBufferFrom(input) { - if (input == null) throw new Error('No binary data provided'); - if (input instanceof ArrayBuffer) return input.slice(0); + if (input == null) { + throw new Error('No binary data provided'); + } + if (input instanceof ArrayBuffer) { + return input.slice(0); + } if (ArrayBuffer.isView(input)) { return input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); } - if (typeof Blob !== 'undefined' && input instanceof Blob) { + if (typeof input === 'string') { + return await arrayBufferFromString(input); + } + if (hasBlob && input instanceof Blob) { return await input.arrayBuffer(); } if (typeof File !== 'undefined' && input instanceof File) { return await input.arrayBuffer(); } if (typeof input === 'object' && typeof input.arrayBuffer === 'function') { - const ab = await input.arrayBuffer(); - if (!(ab instanceof ArrayBuffer)) { + const buffer = await input.arrayBuffer(); + if (!(buffer instanceof ArrayBuffer)) { throw new Error('arrayBuffer() did not return an ArrayBuffer'); } - return ab; + return buffer; } if (hasBuffer && Buffer.isBuffer?.(input)) { return input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); @@ -105,3 +140,20 @@ export function base64FromArrayBuffer(buffer) { } throw new Error('Base64 conversion is not supported in this environment'); } + +async function arrayBufferFromString(value) { + if (typeof TextEncoder !== 'undefined') { + return new TextEncoder().encode(value).buffer; + } + if (hasBuffer) { + const buf = Buffer.from(String(value)); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + } + throw new Error('String to ArrayBuffer conversion is not supported in this environment'); +} + +if (typeof Symbol === 'function' && typeof Symbol.dispose === 'symbol') { + BinaryData.prototype[Symbol.dispose] = function disposeBinaryData() { + this.revokeObjectUrl(); + }; +} diff --git a/Libs/pollilib/src/client.js b/Libs/pollilib/src/client.js index 731c0e3..c9c3c5b 100644 --- a/Libs/pollilib/src/client.js +++ b/Libs/pollilib/src/client.js @@ -1,28 +1,40 @@ -const DEFAULT_IMAGE_BASE = 'https://image.pollinations.ai'; -const DEFAULT_TEXT_BASE = 'https://text.pollinations.ai'; +const DEFAULT_ENDPOINTS = { + image: 'https://image.pollinations.ai', + text: 'https://text.pollinations.ai', +}; export class PolliClient { - constructor({ - fetch: fetchImpl, - imageBase = DEFAULT_IMAGE_BASE, - textBase = DEFAULT_TEXT_BASE, - timeoutMs = 60_000, - auth, - referrer, - token, - tokenProvider, - defaultHeaders = {}, - } = {}) { + constructor(options = {}) { + const { + fetch: fetchImpl, + imageBase, + textBase, + endpoints = {}, + timeoutMs = 60_000, + auth, + referrer, + token, + tokenProvider, + defaultHeaders = {}, + } = options ?? {}; + const impl = fetchImpl ?? globalThis.fetch; if (typeof impl !== 'function') { throw new Error('PolliClient requires a fetch implementation'); } - this.fetch = (...args) => impl(...args); - this.imageBase = stripTrailingSlash(imageBase); - this.textBase = stripTrailingSlash(textBase); - this.timeoutMs = timeoutMs; - this.defaultHeaders = { ...defaultHeaders }; - this._auth = resolveAuth({ auth, referrer, token, tokenProvider }); + + this.fetch = bindFetch(impl); + + const resolvedBases = resolveBases({ + imageBase: imageBase ?? endpoints.image, + textBase: textBase ?? endpoints.text, + }); + + this.imageBase = resolvedBases.image; + this.textBase = resolvedBases.text; + this.timeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 60_000; + this.defaultHeaders = normalizeHeaderBag(defaultHeaders); + this._auth = createAuthManager({ auth, referrer, token, tokenProvider }); } get authMode() { @@ -37,65 +49,83 @@ export class PolliClient { return this._auth.mode === 'token' ? this._auth.placement : null; } - async get(url, { params = {}, headers = {}, includeReferrer = true, timeoutMs } = {}) { - const finalHeaders = { ...this.defaultHeaders, ...(headers || {}) }; - const u = new URL(url); - for (const [key, value] of Object.entries(params ?? {})) { - if (value == null) continue; - u.searchParams.set(key, String(value)); - } - await applyAuthToGet(this, u, finalHeaders, includeReferrer !== false); - const { signal, cancel } = this._createAbort(timeoutMs); - try { - const init = { method: 'GET', headers: finalHeaders }; - if (signal) init.signal = signal; - return await this.fetch(u.toString(), init); - } finally { - cancel(); - } + async get(url, options = {}) { + return await this._request('GET', url, options); } - async postJson(url, body, { headers = {}, params = {}, includeReferrer = true, timeoutMs } = {}) { - const finalHeaders = { 'Content-Type': 'application/json', ...this.defaultHeaders, ...(headers || {}) }; - const u = new URL(url); - for (const [key, value] of Object.entries(params ?? {})) { - if (value == null) continue; - u.searchParams.set(key, String(value)); + async post(url, body, options = {}) { + return await this._request('POST', url, { ...options, body }); + } + + async postJson(url, body, options = {}) { + return await this._request('POST', url, { ...options, body, json: true }); + } + + async request(method, url, options = {}) { + return await this._request(method, url, options); + } + + async getSignedUrl( + url, + { params = {}, includeReferrer = true, includeToken = false, tokenPlacement } = {}, + ) { + const target = buildUrl(url, params); + await this._auth.decorateUrl(target, { includeReferrer, includeToken, tokenPlacement }); + return target.toString(); + } + + async _request( + method, + url, + { + params = {}, + headers = {}, + body, + json, + includeReferrer = true, + includeToken = true, + tokenPlacement, + timeoutMs, + } = {}, + ) { + const target = buildUrl(url, params); + const headerBag = mergeHeaders(this.defaultHeaders, headers); + + const payload = cloneBody(body); + const context = { + method, + url: target, + headers: headerBag, + body: payload, + includeReferrer, + includeToken, + tokenPlacement, + }; + await this._auth.apply(context); + + const init = { method, headers: headerBag }; + if (method !== 'GET' && method !== 'HEAD') { + const preparedBody = prepareBody(payload, headerBag, json); + if (preparedBody !== undefined) { + init.body = preparedBody; + } } - const payload = body ? { ...body } : {}; - await applyAuthToPost(this, u, finalHeaders, payload, includeReferrer !== false); - const json = JSON.stringify(payload); + const { signal, cancel } = this._createAbort(timeoutMs); + if (signal) { + init.signal = signal; + } + try { - const init = { method: 'POST', headers: finalHeaders, body: json }; - if (signal) init.signal = signal; - return await this.fetch(u.toString(), init); + return await this.fetch(target.toString(), init); } finally { cancel(); } } - async getSignedUrl(url, { params = {}, includeReferrer = true, includeToken = false, tokenPlacement } = {}) { - const u = new URL(url); - for (const [key, value] of Object.entries(params ?? {})) { - if (value == null) continue; - u.searchParams.set(key, String(value)); - } - const auth = this._auth; - if (includeReferrer !== false && auth.referrer && !u.searchParams.has('referrer')) { - u.searchParams.set('referrer', auth.referrer); - } - if (includeToken && auth.mode === 'token') { - normalizePlacement(tokenPlacement ?? auth.placement); - const token = await auth.getToken(); - if (token) embedTokenIntoQuery(u, token); - } - return u.toString(); - } - _createAbort(timeoutOverride) { - const timeout = timeoutOverride ?? this.timeoutMs; - if (!Number.isFinite(timeout) || timeout <= 0) { + const timeout = resolveTimeout(timeoutOverride, this.timeoutMs); + if (!timeout) { return { signal: undefined, cancel: () => {} }; } const controller = new AbortController(); @@ -118,69 +148,277 @@ export function setDefaultClient(client) { return client; } +function bindFetch(fn) { + if (fn === globalThis.fetch) { + return (...args) => fn(...args); + } + if (typeof fn.bind === 'function') { + return fn.bind(globalThis); + } + return (...args) => fn(...args); +} + +function resolveBases({ imageBase, textBase }) { + return { + image: stripTrailingSlash(imageBase ?? DEFAULT_ENDPOINTS.image), + text: stripTrailingSlash(textBase ?? DEFAULT_ENDPOINTS.text), + }; +} + function stripTrailingSlash(value) { if (!value) return value; - let out = value; + let out = String(value); while (out.length > 1 && out.endsWith('/')) { out = out.slice(0, -1); } return out; } -function inferReferrer() { - try { - if (typeof window !== 'undefined' && window.location?.origin) { - return window.location.origin; +function resolveTimeout(override, fallback) { + const timeout = override ?? fallback; + if (!Number.isFinite(timeout) || timeout <= 0) { + return 0; + } + return timeout; +} + +function mergeHeaders(base, extra) { + const bag = { ...base }; + const additions = normalizeHeaderBag(extra); + for (const [key, value] of Object.entries(additions)) { + bag[key] = value; + } + return bag; +} + +function normalizeHeaderBag(input) { + if (!input) return {}; + if (input instanceof Headers) { + const bag = {}; + input.forEach((value, key) => { + bag[key] = value; + }); + return bag; + } + if (Array.isArray(input)) { + const bag = {}; + for (const entry of input) { + if (!entry) continue; + const [key, value] = entry; + if (key == null || value == null) continue; + bag[String(key)] = String(value); } - if (typeof document !== 'undefined' && document.location?.origin) { - return document.location.origin; + return bag; + } + const bag = {}; + for (const [key, value] of Object.entries(input)) { + if (value == null) continue; + bag[String(key)] = String(value); + } + return bag; +} + +function buildUrl(input, params) { + const url = input instanceof URL ? new URL(input.toString()) : createUrl(String(input)); + for (const [key, value] of Object.entries(params ?? {})) { + if (value == null) continue; + url.searchParams.set(key, String(value)); + } + return url; +} + +function createUrl(value) { + if (/^https?:\/\//iu.test(value)) { + return new URL(value); + } + throw new Error(`PolliClient requires absolute URLs. Received: ${value}`); +} + +function cloneBody(body) { + if (body == null) return body; + if (Array.isArray(body)) { + return [...body]; + } + if (isBodyObject(body)) { + return { ...body }; + } + return body; +} + +function prepareBody(payload, headers, jsonFlag) { + if (payload == null) { + return payload === null ? null : undefined; + } + + if (typeof payload === 'string') { + return payload; + } + + if (payload instanceof ArrayBuffer || ArrayBuffer.isView(payload)) { + return payload; + } + + if (typeof Blob !== 'undefined' && payload instanceof Blob) { + return payload; + } + + if (typeof FormData !== 'undefined' && payload instanceof FormData) { + return payload; + } + + if (typeof URLSearchParams !== 'undefined' && payload instanceof URLSearchParams) { + return payload; + } + + if (typeof ReadableStream !== 'undefined' && payload instanceof ReadableStream) { + return payload; + } + + if (shouldSerializeAsJson(payload, jsonFlag)) { + if (!hasContentType(headers)) { + headers['Content-Type'] = 'application/json'; } - } catch { - // ignore access errors + return JSON.stringify(payload); } - return null; + + return payload; +} + +function shouldSerializeAsJson(value, flag) { + if (flag === true) return true; + if (flag === false) return false; + return Array.isArray(value) || isBodyObject(value); +} + +function hasContentType(headers) { + return Object.keys(headers).some(key => key.toLowerCase() === 'content-type'); +} + +function isBodyObject(value) { + if (!value || typeof value !== 'object') return false; + if (Array.isArray(value)) return false; + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return false; + if (typeof Blob !== 'undefined' && value instanceof Blob) return false; + if (typeof FormData !== 'undefined' && value instanceof FormData) return false; + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) return false; + if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; } -function resolveAuth({ auth, referrer, token, tokenProvider } = {}) { +function createAuthManager({ auth, referrer, token, tokenProvider } = {}) { const fallbackReferrer = referrer ?? inferReferrer(); if (auth) { if (auth.mode === 'none') { - return { mode: 'none', referrer: null }; + return new AuthManager({ mode: 'none', referrer: null, placement: 'header', getToken: async () => null }); } if (auth.mode === 'referrer') { - const ref = auth.referrer ?? fallbackReferrer; - if (!ref) throw new Error('Referrer authentication requires a referrer string'); - return { mode: 'referrer', referrer: String(ref) }; + const resolvedReferrer = auth.referrer ?? fallbackReferrer; + if (!resolvedReferrer) { + throw new Error('Referrer authentication requires a referrer string'); + } + return new AuthManager({ mode: 'referrer', referrer: String(resolvedReferrer), placement: 'header', getToken: async () => null }); } if (auth.mode === 'token') { const provider = normalizeTokenProvider(auth.getToken ?? auth.token ?? tokenProvider ?? token); - if (!provider) throw new Error('Token authentication requires a token or provider'); - return { + if (!provider) { + throw new Error('Token authentication requires a token or provider'); + } + return new AuthManager({ mode: 'token', - getToken: provider, - placement: normalizePlacement(auth.placement), referrer: auth.referrer ?? fallbackReferrer ?? null, - }; + placement: normalizePlacement(auth.placement), + getToken: provider, + }); } throw new Error(`Unsupported auth.mode: ${auth.mode}`); } if (tokenProvider || token) { const provider = normalizeTokenProvider(tokenProvider ?? token); - if (!provider) throw new Error('Token authentication requires a token or provider'); - return { + if (!provider) { + throw new Error('Token authentication requires a token or provider'); + } + return new AuthManager({ mode: 'token', - getToken: provider, - placement: 'header', referrer: fallbackReferrer ?? null, - }; + placement: 'header', + getToken: provider, + }); } if (fallbackReferrer) { - return { mode: 'referrer', referrer: String(fallbackReferrer) }; + return new AuthManager({ + mode: 'referrer', + referrer: String(fallbackReferrer), + placement: 'header', + getToken: async () => null, + }); + } + + return new AuthManager({ mode: 'none', referrer: null, placement: 'header', getToken: async () => null }); +} + +class AuthManager { + constructor({ mode, referrer, placement, getToken }) { + this.mode = mode; + this.referrer = referrer ?? null; + this.placement = normalizePlacement(placement ?? 'header'); + this._getToken = getToken; + } + + async getToken() { + if (this.mode !== 'token') return null; + return await this._getToken(); + } + + async apply({ method, url, headers, body, includeReferrer, includeToken, tokenPlacement }) { + if (includeReferrer !== false && this.referrer) { + if ((method === 'GET' || method === 'HEAD') && !url.searchParams.has('referrer')) { + url.searchParams.set('referrer', this.referrer); + } else if (isBodyObject(body) && body.referrer == null) { + body.referrer = this.referrer; + } else if (!url.searchParams.has('referrer')) { + url.searchParams.set('referrer', this.referrer); + } + } + + if (this.mode !== 'token' || includeToken === false) { + return; + } + + const token = await this.getToken(); + if (!token) { + return; + } + + const placement = normalizePlacement(tokenPlacement ?? this.placement); + embedTokenIntoQuery(url, token); + + if (placement === 'header') { + if (!hasAuthHeader(headers)) { + headers['Authorization'] = `Bearer ${token}`; + } + } else if (placement === 'body') { + if (isBodyObject(body) && body.token == null) { + body.token = token; + } + } } - return { mode: 'none', referrer: null }; + async decorateUrl(url, { includeReferrer = true, includeToken = false, tokenPlacement } = {}) { + if (includeReferrer && this.referrer && !url.searchParams.has('referrer')) { + url.searchParams.set('referrer', this.referrer); + } + if (includeToken && this.mode === 'token') { + const token = await this.getToken(); + if (!token) { + return; + } + embedTokenIntoQuery(url, token); + normalizePlacement(tokenPlacement ?? this.placement); + } + } } function normalizeTokenProvider(source) { @@ -193,7 +431,9 @@ function normalizeTokenProvider(source) { function extractToken(value) { if (value == null) return null; - if (typeof value === 'string') return value.trim(); + if (typeof value === 'string') { + return value.trim(); + } if (typeof value === 'object' && 'token' in value) { return extractToken(value.token); } @@ -210,42 +450,7 @@ function normalizePlacement(value) { } function hasAuthHeader(headers) { - if (!headers) return false; - return Object.keys(headers).some(key => key.toLowerCase() === 'authorization'); -} - -async function applyAuthToGet(client, url, headers, includeReferrer) { - const auth = client._auth; - if (includeReferrer && auth.referrer && !url.searchParams.has('referrer')) { - url.searchParams.set('referrer', auth.referrer); - } - if (auth.mode === 'token') { - const token = await auth.getToken(); - if (!token) return; - embedTokenIntoQuery(url, token); - const placement = normalizePlacement(auth.placement); - if (placement === 'header' && !hasAuthHeader(headers)) { - headers['Authorization'] = `Bearer ${token}`; - } - } -} - -async function applyAuthToPost(client, url, headers, payload, includeReferrer) { - const auth = client._auth; - if (includeReferrer && auth.referrer && payload.referrer == null) { - payload.referrer = auth.referrer; - } - if (auth.mode === 'token') { - const token = await auth.getToken(); - if (!token) return; - const placement = normalizePlacement(auth.placement); - embedTokenIntoQuery(url, token); - if (placement === 'header') { - if (!hasAuthHeader(headers)) headers['Authorization'] = `Bearer ${token}`; - } else if (placement !== 'query') { - if (payload.token == null) payload.token = token; - } - } + return Object.keys(headers ?? {}).some(key => key.toLowerCase() === 'authorization'); } function embedTokenIntoQuery(url, token) { @@ -254,3 +459,17 @@ function embedTokenIntoQuery(url, token) { url.searchParams.set('token', token); } } + +function inferReferrer() { + try { + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + if (typeof document !== 'undefined' && document.location?.origin) { + return document.location.origin; + } + } catch { + // Ignore DOM access errors in non-browser environments. + } + return null; +} diff --git a/Libs/pollilib/src/errors.js b/Libs/pollilib/src/errors.js index f6e4638..331e8fd 100644 --- a/Libs/pollilib/src/errors.js +++ b/Libs/pollilib/src/errors.js @@ -1,28 +1,97 @@ -export class PolliRequestError extends Error { - constructor(operation, response, bodyText = null) { - const status = response?.status ?? 'unknown'; +const JSON_CONTENT_TYPE = /application\/json/i; + +export class PollinationsHttpError extends Error { + constructor({ operation, response, body, requestId }) { + const status = response?.status ?? 0; const statusText = response?.statusText ?? ''; - const detail = bodyText ? `: ${bodyText}` : ''; - super(`Pollinations ${operation} request failed (${status} ${statusText})${detail}`); - this.name = 'PolliRequestError'; + const message = buildMessage({ operation, status, statusText, requestId, body }); + super(message); + this.name = 'PollinationsHttpError'; this.operation = operation; this.status = status; this.statusText = statusText; - this.body = bodyText; - this.headers = response?.headers ?? null; + this.requestId = requestId ?? null; + this.body = body ?? null; + this.headers = headersToObject(response?.headers); } } export async function raiseForStatus(response, operation, { consumeBody = true } = {}) { - if (response.ok) return response; - let bodyText = null; - if (consumeBody) { - try { - bodyText = await response.text(); - } catch { - bodyText = null; + if (response?.ok) { + return response; + } + + const body = consumeBody !== false ? await readBody(response) : null; + const requestId = extractRequestId(response); + + throw new PollinationsHttpError({ + operation, + response, + body, + requestId, + }); +} + +function buildMessage({ operation, status, statusText, requestId, body }) { + const statusLabel = status ? `${status}${statusText ? ` ${statusText}` : ''}` : 'unknown status'; + const header = operation ? `Pollinations ${operation} request failed` : 'Pollinations request failed'; + const parts = [`${header} (${statusLabel})`]; + if (requestId) { + parts.push(`request ${requestId}`); + } + if (body) { + if (typeof body === 'string') { + parts.push(body); + } else { + try { + parts.push(JSON.stringify(body)); + } catch { + // ignore stringify issues + } + } + } + return parts.join(' | '); +} + +async function readBody(response) { + if (!response) return null; + const target = typeof response.clone === 'function' ? response.clone() : response; + try { + const contentType = target.headers?.get?.('content-type') ?? ''; + if (JSON_CONTENT_TYPE.test(contentType) && typeof target.json === 'function') { + return await target.json(); + } + if (typeof target.text === 'function') { + const text = await target.text(); + return text ? text.trim() : null; } - if (bodyText) bodyText = bodyText.trim(); + } catch { + // ignore body parsing failures + } + return null; +} + +function extractRequestId(response) { + if (!response?.headers?.get) return null; + return ( + response.headers.get('x-request-id') ?? + response.headers.get('x-amzn-requestid') ?? + response.headers.get('x-amz-request-id') ?? + null + ); +} + +function headersToObject(headers) { + if (!headers) return {}; + if (headers instanceof Headers) { + const out = {}; + headers.forEach((value, key) => { + out[key] = value; + }); + return out; + } + if (Array.isArray(headers)) { + return Object.fromEntries(headers); } - throw new PolliRequestError(operation, response, bodyText); + return { ...headers }; } diff --git a/Libs/pollilib/src/feeds.js b/Libs/pollilib/src/feeds.js index 8f0f339..04d3a67 100644 --- a/Libs/pollilib/src/feeds.js +++ b/Libs/pollilib/src/feeds.js @@ -2,42 +2,47 @@ import { getDefaultClient } from './client.js'; import { sseEvents } from './sse.js'; import { raiseForStatus } from './errors.js'; -export async function* imageFeed({ limit, timeoutMs } = {}, client = getDefaultClient()) { - const response = await client.get(`${client.imageBase}/feed`, { - headers: { Accept: 'text/event-stream' }, - timeoutMs: timeoutMs ?? 0, - }); - if (!response.ok) { - await raiseForStatus(response, 'imageFeed', { consumeBody: false }); - } - let count = 0; - for await (const chunk of sseEvents(response)) { - try { - const obj = JSON.parse(chunk); - yield obj; - if (limit != null && ++count >= limit) break; - } catch { - // ignore malformed payloads - } - } +export async function* imageFeed(options = {}, client = getDefaultClient()) { + const url = `${client.imageBase}/feed`; + yield* createFeedStream(url, { ...options, name: 'imageFeed' }, client); +} + +export async function* textFeed(options = {}, client = getDefaultClient()) { + const url = `${client.textBase}/feed`; + yield* createFeedStream(url, { ...options, name: 'textFeed' }, client); } -export async function* textFeed({ limit, timeoutMs } = {}, client = getDefaultClient()) { - const response = await client.get(`${client.textBase}/feed`, { +async function* createFeedStream(url, options, client) { + const { limit, timeoutMs, signal, params, onError } = options ?? {}; + + const response = await client.get(url, { + params, headers: { Accept: 'text/event-stream' }, timeoutMs: timeoutMs ?? 0, }); + if (!response.ok) { - await raiseForStatus(response, 'textFeed', { consumeBody: false }); + await raiseForStatus(response, options?.name ?? 'feed', { consumeBody: false }); + return; } + let count = 0; - for await (const chunk of sseEvents(response)) { + for await (const chunk of sseEvents(response, { signal })) { + const trimmed = chunk.trim(); + if (!trimmed || trimmed === '[DONE]') { + if (trimmed === '[DONE]') break; + continue; + } try { - const obj = JSON.parse(chunk); - yield obj; - if (limit != null && ++count >= limit) break; - } catch { - // ignore malformed payloads + const parsed = JSON.parse(chunk); + yield parsed; + if (limit != null && ++count >= limit) { + break; + } + } catch (error) { + if (typeof onError === 'function') { + onError(error, chunk); + } } } } diff --git a/Libs/pollilib/src/image.js b/Libs/pollilib/src/image.js index 32c5a40..2f0a43f 100644 --- a/Libs/pollilib/src/image.js +++ b/Libs/pollilib/src/image.js @@ -2,45 +2,126 @@ import { getDefaultClient } from './client.js'; import { BinaryData } from './binary.js'; import { raiseForStatus } from './errors.js'; -const boolString = value => (value == null ? undefined : value ? 'true' : 'false'); - export async function image(prompt, options = {}, client = getDefaultClient()) { - if (typeof prompt !== 'string' || !prompt.length) { - throw new Error('image() expects a non-empty prompt string'); - } - const { - model, - seed, - width, - height, - image: imageUrl, - nologo, - private: priv, - enhance, - safe, - referrer, - timeoutMs, - } = options; - const url = `${client.imageBase}/prompt/${encodeURIComponent(prompt)}`; - const params = {}; - if (model) params.model = model; - if (seed != null) params.seed = seed; - if (width != null) params.width = width; - if (height != null) params.height = height; - if (imageUrl) params.image = imageUrl; - if (nologo != null) params.nologo = boolString(nologo); - if (priv != null) params.private = boolString(priv); - if (enhance != null) params.enhance = boolString(enhance); - if (safe != null) params.safe = boolString(safe); - if (referrer) params.referrer = referrer; + const normalizedPrompt = normalizePrompt(prompt); + const { timeoutMs, ...rest } = options ?? {}; + const params = buildImageParams(rest); + const url = `${client.imageBase}/prompt/${encodeURIComponent(normalizedPrompt)}`; const response = await client.get(url, { params, timeoutMs }); await raiseForStatus(response, 'image'); return await BinaryData.fromResponse(response); } +export async function imageUrl(prompt, options = {}, client = getDefaultClient()) { + const normalizedPrompt = normalizePrompt(prompt); + const params = buildImageParams(options ?? {}); + const url = `${client.imageBase}/prompt/${encodeURIComponent(normalizedPrompt)}`; + return await client.getSignedUrl(url, { params, includeToken: true }); +} + export async function imageModels(client = getDefaultClient()) { const response = await client.get(`${client.imageBase}/models`); await raiseForStatus(response, 'imageModels'); return await response.json(); } + +function normalizePrompt(prompt) { + if (typeof prompt !== 'string') { + throw new Error('image() expects the prompt to be a string'); + } + const trimmed = prompt.trim(); + if (!trimmed) { + throw new Error('image() requires a non-empty prompt string'); + } + return trimmed; +} + +function buildImageParams(options) { + const params = {}; + const extras = { ...options }; + + assignIfPresent(params, 'model', extras.model); + delete extras.model; + + assignIfPresent(params, 'seed', extras.seed); + delete extras.seed; + + assignIfPresent(params, 'width', extras.width); + delete extras.width; + + assignIfPresent(params, 'height', extras.height); + delete extras.height; + + assignIfPresent(params, 'size', extras.size); + delete extras.size; + + assignIfPresent(params, 'aspect_ratio', extras.aspect_ratio ?? extras.aspectRatio); + delete extras.aspect_ratio; + delete extras.aspectRatio; + + assignIfPresent(params, 'background', extras.background); + delete extras.background; + + assignIfPresent(params, 'image', extras.image ?? extras.imageUrl); + delete extras.image; + delete extras.imageUrl; + + assignIfPresent(params, 'mask', extras.mask); + delete extras.mask; + + assignBooleanParam(params, 'nologo', pickFirst(extras, ['nologo', 'noLogo'])); + delete extras.nologo; + delete extras.noLogo; + + assignBooleanParam(params, 'private', pickFirst(extras, ['private', 'isPrivate'])); + delete extras.private; + delete extras.isPrivate; + + assignBooleanParam(params, 'enhance', extras.enhance); + delete extras.enhance; + + assignBooleanParam(params, 'safe', extras.safe); + delete extras.safe; + + assignBooleanParam(params, 'upscale', extras.upscale); + delete extras.upscale; + + assignBooleanParam(params, 'high_contrast', extras.high_contrast ?? extras.highContrast); + delete extras.high_contrast; + delete extras.highContrast; + + if ('referrer' in extras && extras.referrer) { + params.referrer = extras.referrer; + delete extras.referrer; + } + + delete extras.timeoutMs; + + for (const [key, value] of Object.entries(extras)) { + if (value === undefined || value === null) continue; + params[key] = value; + } + + return params; +} + +function assignIfPresent(target, key, value) { + if (value !== undefined && value !== null && value !== '') { + target[key] = value; + } +} + +function assignBooleanParam(target, key, value) { + if (value == null) return; + target[key] = value ? 'true' : 'false'; +} + +function pickFirst(source, keys) { + for (const key of keys) { + if (key in source && source[key] !== undefined) { + return source[key]; + } + } + return undefined; +} diff --git a/Libs/pollilib/src/mcp.js b/Libs/pollilib/src/mcp.js index dbc10a3..b4b10a6 100644 --- a/Libs/pollilib/src/mcp.js +++ b/Libs/pollilib/src/mcp.js @@ -1,7 +1,7 @@ import { getDefaultClient } from './client.js'; -import { image as imageGen, imageModels as listImage } from './image.js'; -import { textModels as listText } from './text.js'; -import { tts as ttsGen } from './audio.js'; +import { image, imageModels, imageUrl } from './image.js'; +import { textModels } from './text.js'; +import { tts } from './audio.js'; export function serverName() { return 'pollinations-multimodal-api'; @@ -13,83 +13,47 @@ export function toolDefinitions() { tools: [ { name: 'generateImageUrl', - description: 'Generate an image and return its URL', - parameters: { - type: 'object', - properties: { - prompt: { type: 'string' }, - model: { type: 'string' }, - seed: { type: 'integer' }, - width: { type: 'integer' }, - height: { type: 'integer' }, - nologo: { type: 'boolean' }, - private: { type: 'boolean' }, - }, - required: ['prompt'], - }, + description: 'Generate an image and return its signed URL', + parameters: imageParameters(), }, { name: 'generateImage', - description: 'Generate an image and return base64', - parameters: { - type: 'object', - properties: { - prompt: { type: 'string' }, - model: { type: 'string' }, - seed: { type: 'integer' }, - width: { type: 'integer' }, - height: { type: 'integer' }, - }, - required: ['prompt'], - }, + description: 'Generate an image and return base64 data', + parameters: imageParameters(), }, { name: 'respondAudio', description: 'Generate text-to-speech audio and return base64', - parameters: { - type: 'object', - properties: { - text: { type: 'string' }, - voice: { type: 'string' }, - model: { type: 'string' }, - }, - required: ['text'], - }, + parameters: audioParameters(), }, { name: 'sayText', description: 'Alias for respondAudio', - parameters: { - type: 'object', - properties: { - text: { type: 'string' }, - voice: { type: 'string' }, - model: { type: 'string' }, - }, - required: ['text'], - }, + parameters: audioParameters(), }, { name: 'listImageModels', description: 'List available image models', - parameters: { type: 'object', properties: {} }, + parameters: emptyParameters(), }, { name: 'listTextModels', - description: 'List text & multimodal models', - parameters: { type: 'object', properties: {} }, + description: 'List text and multimodal models', + parameters: emptyParameters(), }, { name: 'listAudioVoices', - description: 'List available voices', - parameters: { type: 'object', properties: {} }, + description: 'List available audio voices', + parameters: emptyParameters(), }, { name: 'listModels', description: 'List models by kind', parameters: { type: 'object', - properties: { kind: { type: 'string', enum: ['image', 'text', 'audio'] } }, + properties: { + kind: { type: 'string', enum: ['image', 'text', 'audio'] }, + }, }, }, ], @@ -97,61 +61,51 @@ export function toolDefinitions() { } export async function generateImageUrl(client, params) { - ({ client, params } = ensureClientArgs(client, params)); - if (!params?.prompt) throw new Error('generateImageUrl requires a prompt'); - const baseUrl = `${client.imageBase}/prompt/${encodeURIComponent(params.prompt)}`; - const { prompt, ...rest } = params; - try { - return await client.getSignedUrl(baseUrl, { params: rest, includeToken: true }); - } catch (err) { - if (String(err.message).includes('Token can only be embedded')) { - return await client.getSignedUrl(baseUrl, { params: rest, includeToken: false }); - } - throw err; - } + const { resolvedClient, resolvedParams } = resolveClientArgs(client, params); + const { prompt, ...options } = resolvedParams; + if (!prompt) throw new Error('generateImageUrl requires a prompt'); + return await imageUrl(prompt, options, resolvedClient); } export async function generateImageBase64(client, params) { - ({ client, params } = ensureClientArgs(client, params)); - if (!params?.prompt) throw new Error('generateImage requires a prompt'); - const data = await imageGen(params.prompt, params, client); + const { resolvedClient, resolvedParams } = resolveClientArgs(client, params); + const { prompt, ...options } = resolvedParams; + if (!prompt) throw new Error('generateImage requires a prompt'); + const data = await image(prompt, options, resolvedClient); return await data.toBase64(); } export async function listImageModels(client, params) { - ({ client } = ensureClientArgs(client, params)); - return await listImage(client); + const { resolvedClient } = resolveClientArgs(client, params); + return await imageModels(resolvedClient); } export async function listTextModels(client, params) { - ({ client } = ensureClientArgs(client, params)); - return await listText(client); + const { resolvedClient } = resolveClientArgs(client, params); + return await textModels(resolvedClient); } export async function listAudioVoices(client, params) { - ({ client } = ensureClientArgs(client, params)); - const models = await listText(client); - return models?.['openai-audio']?.voices ?? []; + const { resolvedClient } = resolveClientArgs(client, params); + const models = await textModels(resolvedClient); + return extractAudioModels(models); } export async function listModels(client, params = {}) { - ({ client, params } = ensureClientArgs(client, params)); - const kind = params?.kind; + const { resolvedClient, resolvedParams } = resolveClientArgs(client, params); + const kind = resolvedParams?.kind; if (kind === 'image') { - return await listImage(client); - } - const textModels = await listText(client); - if (kind === 'audio') { - return extractAudioModels(textModels); + return await imageModels(resolvedClient); } + const text = await textModels(resolvedClient); if (kind === 'text') { - return textModels; + return text; + } + if (kind === 'audio') { + return extractAudioModels(text); } - const [imageModels, audioModels] = await Promise.all([ - listImage(client), - Promise.resolve(extractAudioModels(textModels)), - ]); - return { image: imageModels, text: textModels, audio: audioModels }; + const [images, audio] = await Promise.all([imageModels(resolvedClient), Promise.resolve(extractAudioModels(text))]); + return { image: images, text, audio }; } export async function respondAudio(client, params) { @@ -159,22 +113,55 @@ export async function respondAudio(client, params) { } export async function sayText(client, params) { - ({ client, params } = ensureClientArgs(client, params)); - if (!params?.text) throw new Error('sayText requires text'); - const binary = await ttsGen(params.text, { voice: params.voice, model: params.model }, client); + const { resolvedClient, resolvedParams } = resolveClientArgs(client, params); + const { text: message, voice, model } = resolvedParams; + if (!message) throw new Error('sayText requires text'); + const audio = await tts(message, { voice, model }, resolvedClient); return { - base64: await binary.toBase64(), - mimeType: binary.mimeType, - dataUrl: binary.toDataUrl(), + base64: await audio.toBase64(), + mimeType: audio.mimeType, + dataUrl: audio.toDataUrl(), }; } +function imageParameters() { + return { + type: 'object', + properties: { + prompt: { type: 'string' }, + model: { type: 'string' }, + seed: { type: 'integer' }, + width: { type: 'integer' }, + height: { type: 'integer' }, + nologo: { type: 'boolean' }, + private: { type: 'boolean' }, + }, + required: ['prompt'], + }; +} + +function audioParameters() { + return { + type: 'object', + properties: { + text: { type: 'string' }, + voice: { type: 'string' }, + model: { type: 'string' }, + }, + required: ['text'], + }; +} + +function emptyParameters() { + return { type: 'object', properties: {} }; +} + function extractAudioModels(models) { const audio = {}; if (!models || typeof models !== 'object') return audio; for (const [name, info] of Object.entries(models)) { - const hasVoices = info?.voices?.length; - const declaresAudio = info?.capabilities?.includes?.('audio'); + const hasVoices = Array.isArray(info?.voices) && info.voices.length > 0; + const declaresAudio = Array.isArray(info?.capabilities) && info.capabilities.includes('audio'); if (name.includes('audio') || hasVoices || declaresAudio) { audio[name] = info; } @@ -182,12 +169,9 @@ function extractAudioModels(models) { return audio; } -function ensureClientArgs(client, params) { - if (!params && (!client || typeof client.getSignedUrl !== 'function')) { - return { client: getDefaultClient(), params: client ?? {} }; - } - if (!client || typeof client.getSignedUrl !== 'function') { - return { client: getDefaultClient(), params: params ?? {} }; +function resolveClientArgs(client, params) { + if (client && typeof client.getSignedUrl === 'function') { + return { resolvedClient: client, resolvedParams: params ?? {} }; } - return { client, params: params ?? {} }; + return { resolvedClient: getDefaultClient(), resolvedParams: client ?? {} }; } diff --git a/Libs/pollilib/src/pipeline.js b/Libs/pollilib/src/pipeline.js index 1bbabe1..6f35e24 100644 --- a/Libs/pollilib/src/pipeline.js +++ b/Libs/pollilib/src/pipeline.js @@ -1,86 +1,88 @@ -import { text as textGet } from './text.js'; -import { image as imageGen } from './image.js'; -import { tts as ttsGen } from './audio.js'; -import { vision as visionAnalyze } from './vision.js'; +import { getDefaultClient } from './client.js'; +import { text } from './text.js'; +import { image } from './image.js'; +import { tts } from './audio.js'; +import { vision } from './vision.js'; -export class Context extends Map {} +export class Context extends Map { + getOrDefault(key, fallback) { + return this.has(key) ? this.get(key) : fallback; + } +} export class Pipeline { - constructor() { - this.steps = []; + constructor(steps = []) { + this._steps = steps.map(normalizeStep); } - step(step) { - this.steps.push(step); + use(step) { + this._steps.push(normalizeStep(step)); return this; } - async execute({ client, context = new Context() } = {}) { - for (const step of this.steps) { - await step.run({ client, context }); + async run({ client = getDefaultClient(), context } = {}) { + const ctx = context instanceof Context ? context : new Context(context ? Object.entries(context) : []); + for (const step of this._steps) { + await step({ client, context: ctx }); } - return context; + return ctx; } } -export class TextGetStep { - constructor({ prompt, outKey, params = {} }) { - this.prompt = prompt; - this.outKey = outKey; - this.params = params; - } - - async run({ client, context }) { - const value = await textGet(this.prompt, this.params, client); - context.set(this.outKey, value); - } +export function textStep({ prompt, storeAs, options = {} }) { + return async ({ client, context }) => { + const resolvedPrompt = resolveValue(prompt, context); + const result = await text(resolvedPrompt, resolveValue(options, context), client); + context.set(storeAs, result); + }; } -export class ImageStep { - constructor({ prompt, outKey, params = {} }) { - this.prompt = prompt; - this.outKey = outKey; - this.params = params; - } - - async run({ client, context }) { - const binary = await imageGen(this.prompt, this.params, client); - context.set(this.outKey, { - binary, - mimeType: binary.mimeType, - base64: await binary.toBase64(), - dataUrl: binary.toDataUrl(), +export function imageStep({ prompt, storeAs, options = {} }) { + return async ({ client, context }) => { + const resolvedPrompt = resolveValue(prompt, context); + const result = await image(resolvedPrompt, resolveValue(options, context), client); + context.set(storeAs, { + binary: result, + mimeType: result.mimeType, + size: result.size, + base64: await result.toBase64(), + dataUrl: result.toDataUrl(), }); - } + }; } -export class TtsStep { - constructor({ text, outKey, params = {} }) { - this.text = text; - this.outKey = outKey; - this.params = params; - } - - async run({ client, context }) { - const binary = await ttsGen(this.text, this.params, client); - context.set(this.outKey, { - binary, - mimeType: binary.mimeType, - base64: await binary.toBase64(), - dataUrl: binary.toDataUrl(), +export function ttsStep({ text: input, storeAs, options = {} }) { + return async ({ client, context }) => { + const resolvedText = resolveValue(input, context); + const result = await tts(resolvedText, resolveValue(options, context), client); + context.set(storeAs, { + binary: result, + mimeType: result.mimeType, + size: result.size, + base64: await result.toBase64(), + dataUrl: result.toDataUrl(), }); - } + }; } -export class VisionUrlStep { - constructor({ imageUrl, outKey, question, params = {} }) { - this.imageUrl = imageUrl; - this.outKey = outKey; - this.params = { question, ...params }; +export function visionStep({ storeAs, options = {} }) { + return async ({ client, context }) => { + const payload = resolveValue(options, context); + const result = await vision(payload, client); + context.set(storeAs, result); + }; +} + +function normalizeStep(step) { + if (typeof step === 'function') { + return step; } + throw new Error('Pipeline steps must be functions'); +} - async run({ client, context }) { - const value = await visionAnalyze({ imageUrl: this.imageUrl, ...this.params }, client); - context.set(this.outKey, value); +function resolveValue(value, context) { + if (typeof value === 'function') { + return value(context); } + return value; } diff --git a/Libs/pollilib/src/sse.js b/Libs/pollilib/src/sse.js index 566367f..c8f06da 100644 --- a/Libs/pollilib/src/sse.js +++ b/Libs/pollilib/src/sse.js @@ -1,35 +1,79 @@ -export async function* sseEvents(response) { +const decoder = new TextDecoder(); + +export async function* sseEvents(response, { signal } = {}) { + if (!response?.body || typeof response.body.getReader !== 'function') { + throw new Error('SSE responses require a readable stream body'); + } + const reader = response.body.getReader(); - const decoder = new TextDecoder(); let buffer = ''; let eventLines = []; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let idx; - while ((idx = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, idx).replace(/\r$/, ''); - buffer = buffer.slice(idx + 1); - if (line === '') { - if (eventLines.length) { - const data = eventLines - .filter(l => l.startsWith('data:')) - .map(l => l.slice(5).trimStart()) - .join('\n'); + + try { + while (true) { + if (signal?.aborted) { + throw signal.reason ?? new DOMException('Aborted', 'AbortError'); + } + + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + let index; + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index).replace(/\r$/, ''); + buffer = buffer.slice(index + 1); + if (line === '') { + const payload = buildEventPayload(eventLines); eventLines = []; - if (data) yield data; + if (payload != null) { + yield payload; + } + } else { + eventLines.push(line); } - } else { - eventLines.push(line); } } + + buffer += decoder.decode(); + if (buffer.length) { + buffer = buffer.replace(/\r/g, ''); + const segments = buffer.split('\n'); + for (const segment of segments) { + if (segment === '') { + const payload = buildEventPayload(eventLines); + eventLines = []; + if (payload != null) { + yield payload; + } + } else { + eventLines.push(segment); + } + } + } + + if (eventLines.length) { + const payload = buildEventPayload(eventLines); + if (payload != null) { + yield payload; + } + } + } finally { + reader.releaseLock?.(); } - if (eventLines.length) { - const data = eventLines - .filter(l => l.startsWith('data:')) - .map(l => l.slice(5).trimStart()) - .join('\n'); - if (data) yield data; +} + +function buildEventPayload(lines) { + if (!lines?.length) return null; + const data = []; + for (const line of lines) { + if (!line.length || line.startsWith(':')) continue; + const separator = line.indexOf(':'); + const field = separator === -1 ? line : line.slice(0, separator); + if (field !== 'data') continue; + const raw = separator === -1 ? '' : line.slice(separator + 1); + data.push(raw.replace(/^\s/, '')); } + if (!data.length) return null; + return data.join('\n'); } diff --git a/Libs/pollilib/src/text.js b/Libs/pollilib/src/text.js index ff178fd..955e006 100644 --- a/Libs/pollilib/src/text.js +++ b/Libs/pollilib/src/text.js @@ -2,38 +2,12 @@ import { getDefaultClient } from './client.js'; import { sseEvents } from './sse.js'; import { raiseForStatus } from './errors.js'; -const boolString = value => (value == null ? undefined : value ? 'true' : 'false'); - export async function text(prompt, options = {}, client = getDefaultClient()) { - if (typeof prompt !== 'string' || !prompt.length) { - throw new Error('text() expects a non-empty prompt string'); - } - const { - model, - seed, - temperature, - top_p, - presence_penalty, - frequency_penalty, - json, - system, - stream, - private: priv, - referrer, - timeoutMs, - } = options; - 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 (json) params.json = json === true ? 'true' : json; - if (system) params.system = system; - if (priv != null) params.private = boolString(priv); - if (referrer) params.referrer = referrer; + const normalizedPrompt = normalizePrompt(prompt); + const { stream = false, timeoutMs, ...rest } = options ?? {}; + + const params = buildTextParams(rest); + const url = `${client.textBase}/${encodeURIComponent(normalizedPrompt)}`; if (stream) { params.stream = 'true'; @@ -47,7 +21,8 @@ export async function text(prompt, options = {}, client = getDefaultClient()) { } return (async function* () { for await (const chunk of sseEvents(response)) { - const trimmed = String(chunk).trim(); + const trimmed = chunk.trim(); + if (!trimmed) continue; if (trimmed === '[DONE]') break; yield chunk; } @@ -60,40 +35,8 @@ export async function text(prompt, options = {}, 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'); - } - const targetEndpoint = resolveChatEndpoint(endpoint); + const { body, stream, timeoutMs } = buildChatPayload(options); 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 (stream) { body.stream = true; @@ -106,7 +49,8 @@ export async function chat(options = {}, client = getDefaultClient()) { } return (async function* () { for await (const chunk of sseEvents(response)) { - const trimmed = String(chunk).trim(); + const trimmed = chunk.trim(); + if (!trimmed) continue; if (trimmed === '[DONE]') break; yield JSON.parse(chunk); } @@ -128,7 +72,231 @@ export async function search(query, model = 'searchgpt', client = getDefaultClie return await text(query, { model }, client); } -function resolveChatEndpoint(endpoint) { +export function createChatSession(initialMessages = [], options = {}, client = getDefaultClient()) { + const state = { + history: normalizeMessages(initialMessages), + options: { ...options }, + }; + + function snapshot() { + return state.history.map(message => ({ ...message })); + } + + function reset(messages = initialMessages) { + state.history = normalizeMessages(messages); + return snapshot(); + } + + function updateOptions(nextOptions = {}) { + state.options = { ...state.options, ...nextOptions }; + return { ...state.options }; + } + + function setOptions(nextOptions = {}) { + state.options = { ...nextOptions }; + return { ...state.options }; + } + + function append(message, defaultRole) { + state.history = [...state.history, normalizeMessage(message, defaultRole)]; + return snapshot(); + } + + async function sendUserMessage(message, overrides = {}) { + append(message, 'user'); + const payload = { ...state.options, ...overrides, messages: state.history }; + const response = await chat(payload, client); + const assistantMessage = extractAssistantMessage(response); + if (assistantMessage) { + state.history = [...state.history, assistantMessage]; + } + return { response, messages: snapshot() }; + } + + return { + get messages() { + return snapshot(); + }, + reset, + updateOptions, + setOptions, + append, + sendUserMessage, + }; +} + +function normalizePrompt(prompt) { + if (typeof prompt !== 'string') { + throw new Error('text() expects the prompt to be a string'); + } + const trimmed = prompt.trim(); + if (!trimmed) { + throw new Error('text() requires a non-empty prompt string'); + } + return trimmed; +} + +function buildTextParams(options) { + const params = {}; + const extras = { ...options }; + + assignIfPresent(params, 'model', extras.model); + delete extras.model; + + assignIfPresent(params, 'seed', extras.seed); + delete extras.seed; + + assignIfPresent(params, 'temperature', pickFirst(extras, ['temperature'])); + delete extras.temperature; + + const topP = pickFirst(extras, ['top_p', 'topP']); + assignIfPresent(params, 'top_p', topP); + delete extras.top_p; + delete extras.topP; + + const presencePenalty = pickFirst(extras, ['presence_penalty', 'presencePenalty']); + assignIfPresent(params, 'presence_penalty', presencePenalty); + delete extras.presence_penalty; + delete extras.presencePenalty; + + const frequencyPenalty = pickFirst(extras, ['frequency_penalty', 'frequencyPenalty']); + assignIfPresent(params, 'frequency_penalty', frequencyPenalty); + delete extras.frequency_penalty; + delete extras.frequencyPenalty; + + const system = pickFirst(extras, ['system', 'systemPrompt']); + assignIfPresent(params, 'system', system); + delete extras.system; + delete extras.systemPrompt; + + const jsonMode = pickFirst(extras, ['jsonMode', 'json']); + if (jsonMode !== undefined) { + params.json = normalizeJsonFlag(jsonMode); + } + delete extras.jsonMode; + delete extras.json; + + if ('private' in extras) { + params.private = boolToString(extras.private); + delete extras.private; + } + + if ('referrer' in extras && extras.referrer) { + params.referrer = extras.referrer; + delete extras.referrer; + } + + for (const [key, value] of Object.entries(extras)) { + if (value === undefined || value === null) continue; + params[key] = value; + } + + return params; +} + +function buildChatPayload(options = {}) { + const extras = { ...options }; + const model = extras.model; + if (!model) { + throw new Error('chat() requires a model'); + } + delete extras.model; + + const messages = normalizeMessages(extras.messages ?? [], extras.system ?? extras.systemPrompt); + if (!messages.length) { + throw new Error('chat() requires at least one message'); + } + + delete extras.messages; + delete extras.system; + delete extras.systemPrompt; + + const body = { model, messages }; + + if ('private' in extras) { + body.private = !!extras.private; + delete extras.private; + } + + const endpoint = extras.endpoint ?? extras.baseEndpoint; + if (endpoint != null) { + const normalizedEndpoint = normalizeChatEndpoint(endpoint); + if (normalizedEndpoint && normalizedEndpoint !== 'openai') { + body.endpoint = normalizedEndpoint; + } + } + delete extras.endpoint; + delete extras.baseEndpoint; + + const format = resolveResponseFormat({ + response_format: extras.response_format, + responseFormat: extras.responseFormat, + jsonMode: extras.jsonMode, + json: extras.json, + }); + if (format.responseFormat !== undefined) { + body.response_format = format.responseFormat; + } + if (format.legacyJson !== undefined) { + body.json = format.legacyJson; + } + delete extras.response_format; + delete extras.responseFormat; + delete extras.jsonMode; + delete extras.json; + + const stream = !!extras.stream; + delete extras.stream; + + const timeoutMs = extras.timeoutMs; + delete extras.timeoutMs; + + for (const [key, value] of Object.entries(extras)) { + if (value === undefined) continue; + body[key] = value; + } + + return { body, stream, timeoutMs }; +} + +function normalizeMessages(messages, systemPrompt) { + const arr = Array.isArray(messages) ? messages.map(message => normalizeMessage(message)) : []; + if (systemPrompt && !arr.some(message => message.role === 'system')) { + arr.unshift({ role: 'system', content: systemPrompt }); + } + return arr; +} + +function normalizeMessage(message, defaultRole = 'user') { + if (typeof message === 'string') { + return { role: defaultRole, content: message }; + } + if (typeof message === 'object' && message) { + const role = message.role ?? defaultRole; + if (!role) { + throw new Error('Chat messages require a role'); + } + if (message.content == null) { + throw new Error('Chat messages require content'); + } + return { role, content: message.content }; + } + throw new Error('Chat messages must be strings or objects with role/content'); +} + +function extractAssistantMessage(response) { + const choices = response?.choices; + if (!Array.isArray(choices) || !choices.length) return null; + for (const choice of choices) { + const message = choice?.message; + if (message?.role === 'assistant' && message.content != null) { + return { role: 'assistant', content: message.content }; + } + } + return null; +} + +function normalizeChatEndpoint(endpoint) { if (endpoint == null) return 'openai'; let value = String(endpoint).trim(); if (!value) return 'openai'; @@ -140,23 +308,22 @@ function resolveChatEndpoint(endpoint) { return 'openai'; } } - value = value.replace(/^\/+/u, '').replace(/\/+$/u, '').toLowerCase(); - return value || 'openai'; + return value.replace(/^\/+/u, '').replace(/\/+$/u, '').toLowerCase() || 'openai'; } -function resolveResponseFormat({ response_format, jsonMode, json }) { - const normalized = normalizeResponseFormat(response_format); - if (normalized !== undefined) { - return { responseFormat: normalized, legacyJson: jsonForLegacy(json, normalized) }; +function resolveResponseFormat({ response_format, responseFormat, jsonMode, json }) { + const direct = normalizeResponseFormat(response_format ?? responseFormat); + if (direct !== undefined) { + return { responseFormat: direct, legacyJson: jsonForLegacy(json, direct) }; } if (jsonMode === true) { return { responseFormat: { type: 'json_object' }, legacyJson: undefined }; } - const jsonAlias = normalizeJsonAlias(json); - if (jsonAlias.responseFormat !== undefined) { - return jsonAlias; + const alias = normalizeJsonAlias(json); + if (alias.responseFormat !== undefined) { + return alias; } - return { responseFormat: undefined, legacyJson: jsonAlias.legacyJson }; + return { responseFormat: undefined, legacyJson: alias.legacyJson }; } function normalizeResponseFormat(value) { @@ -175,6 +342,14 @@ function normalizeResponseFormat(value) { return undefined; } +function jsonForLegacy(json, responseFormat) { + if (!responseFormat || typeof responseFormat !== 'object') return normalizeJsonFlag(json); + if (responseFormat.type === 'json_object') { + return undefined; + } + return normalizeJsonFlag(json); +} + function normalizeJsonAlias(value) { if (value == null) { return { responseFormat: undefined, legacyJson: undefined }; @@ -182,7 +357,7 @@ function normalizeJsonAlias(value) { if (value === true || value === 'true') { return { responseFormat: { type: 'json_object' }, legacyJson: undefined }; } - if (value === false) { + if (value === false || value === 'false') { return { responseFormat: undefined, legacyJson: undefined }; } if (typeof value === 'string') { @@ -198,22 +373,28 @@ function normalizeJsonAlias(value) { return { responseFormat: undefined, legacyJson: value }; } -function jsonForLegacy(value, responseFormat) { +function normalizeJsonFlag(value) { + if (value === true) return 'true'; + if (value === false) return 'false'; 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; + return String(value); +} + +function boolToString(value) { + return value == null ? undefined : value ? 'true' : 'false'; +} + +function assignIfPresent(target, key, value) { + if (value !== undefined && value !== null) { + target[key] = value; } - if (typeof value === 'object') { - if (value === responseFormat) return undefined; - if (value?.type && responseType && String(value.type).toLowerCase() === responseType.toLowerCase()) { - return undefined; +} + +function pickFirst(source, keys) { + for (const key of keys) { + if (key in source && source[key] !== undefined) { + return source[key]; } } - return value; + return undefined; } diff --git a/Libs/pollilib/src/tools.js b/Libs/pollilib/src/tools.js index 4170135..5e203f6 100644 --- a/Libs/pollilib/src/tools.js +++ b/Libs/pollilib/src/tools.js @@ -1,22 +1,45 @@ -import { chat as chatPost } from './text.js'; +import { chat } from './text.js'; import { getDefaultClient } from './client.js'; export function functionTool(name, description, parameters) { - return { type: 'function', function: { name, description, parameters } }; + return { + type: 'function', + function: { name, description, parameters }, + }; } export class ToolBox { - constructor() { - this.map = new Map(); + constructor(entries) { + this._map = new Map(); + if (entries) { + for (const [name, handler] of Object.entries(entries)) { + this.register(name, handler); + } + } } - register(name, fn) { - this.map.set(name, fn); + register(name, handler) { + if (typeof handler !== 'function') { + throw new Error(`Tool '${name}' must be a function`); + } + this._map.set(name, handler); return this; } + has(name) { + return this._map.has(name); + } + get(name) { - return this.map.get(name); + return this._map.get(name); + } + + async invoke(name, args, context) { + const handler = this._map.get(name); + if (!handler) { + throw new Error(`Unknown tool: ${name}`); + } + return await handler(args ?? {}, context); } } @@ -24,44 +47,74 @@ export async function chatWithTools({ client = getDefaultClient(), model, messages, - tools, + tools = [], toolbox = new ToolBox(), maxRounds = 3, - tool_choice, + toolChoice, + onToolCall, } = {}) { - if (!model) throw new Error('chatWithTools requires a model'); - if (!Array.isArray(messages)) throw new Error('chatWithTools requires an array of messages'); + if (!model) { + throw new Error('chatWithTools requires a model'); + } + if (!Array.isArray(messages)) { + throw new Error('chatWithTools requires an array of messages'); + } const history = [...messages]; - for (let round = 0; round <= maxRounds; round++) { - const response = await chatPost({ model, messages: history, tools, tool_choice }, client); + + for (let round = 0; round <= maxRounds; round += 1) { + const response = await chat({ model, messages: history, tools, tool_choice: toolChoice }, client); const choice = response?.choices?.[0]?.message ?? {}; - const toolCalls = choice.tool_calls ?? []; - if (!toolCalls.length) return response; + const toolCalls = Array.isArray(choice.tool_calls) ? choice.tool_calls : []; + + if (!toolCalls.length) { + return response; + } history.push({ role: 'assistant', tool_calls: toolCalls }); for (const call of toolCalls) { const name = call.function?.name; - const fn = toolbox.get(name); - if (!fn) return response; - let args = {}; - if (call.function?.arguments) { - try { - args = JSON.parse(call.function.arguments); - } catch { - args = {}; - } + if (!name || !toolbox.has(name)) { + return response; + } + const args = parseToolArguments(call.function?.arguments); + const context = { round, history: [...history] }; + if (typeof onToolCall === 'function') { + await onToolCall({ name, args, round, history }); } - const result = await fn(args); + const result = await toolbox.invoke(name, args, context); history.push({ role: 'tool', tool_call_id: call.id, name, - content: typeof result === 'string' ? result : JSON.stringify(result), + content: serializeToolResult(result), }); } } - return await chatPost({ model, messages: history }, client); + return await chat({ model, messages: history }, client); +} + +function parseToolArguments(value) { + if (!value) return {}; + if (typeof value === 'object') return value; + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return {}; + } + } + return {}; +} + +function serializeToolResult(result) { + if (result == null) return ''; + if (typeof result === 'string') return result; + try { + return JSON.stringify(result); + } catch { + return String(result); + } } diff --git a/Libs/pollilib/src/vision.js b/Libs/pollilib/src/vision.js index ec60681..9bfaa6b 100644 --- a/Libs/pollilib/src/vision.js +++ b/Libs/pollilib/src/vision.js @@ -2,55 +2,69 @@ import { getDefaultClient } from './client.js'; import { arrayBufferFrom, base64FromArrayBuffer } from './binary.js'; import { raiseForStatus } from './errors.js'; -export async function vision({ - imageUrl, - file, - data, - buffer, - arrayBuffer, - imageFormat, - question, - model = 'openai', - max_tokens, - timeoutMs, -} = {}, client = getDefaultClient()) { - let finalUrl = imageUrl; - if (!finalUrl) { - const bytes = file - ? await arrayBufferFrom(file) - : data - ? await arrayBufferFrom(data) - : arrayBuffer - ? await arrayBufferFrom(arrayBuffer) - : buffer - ? await arrayBufferFrom(buffer) - : null; - if (!bytes) { - throw new Error('vision() requires either imageUrl or image binary data'); - } - const fmt = imageFormat ?? guessImageFormat({ file }); - if (!fmt) { - throw new Error('imageFormat is required when providing raw image bytes'); - } - const b64 = base64FromArrayBuffer(bytes); - finalUrl = `data:image/${fmt};base64,${b64}`; +export async function vision(options = {}, client = getDefaultClient()) { + const payload = await buildVisionPayload(options); + const response = await client.postJson(`${client.textBase}/openai`, payload, { + timeoutMs: options.timeoutMs, + }); + await raiseForStatus(response, 'vision'); + return await response.json(); +} + +async function buildVisionPayload(options = {}) { + const { + imageUrl, + file, + data, + buffer, + arrayBuffer, + imageFormat, + question, + prompt, + model = 'openai', + max_tokens, + temperature, + } = options; + + const url = imageUrl ?? (await createDataUrl({ file, data, buffer, arrayBuffer, imageFormat })); + if (!url) { + throw new Error('vision() requires either imageUrl or image binary data'); } - const payload = { - model, - messages: [{ - role: 'user', - content: [ - { type: 'text', text: question ?? 'Describe this image:' }, - { type: 'image_url', image_url: { url: finalUrl } }, - ], - }], + const userPrompt = question ?? prompt ?? 'Describe this image:'; + + const message = { + role: 'user', + content: [ + { type: 'text', text: userPrompt }, + { type: 'image_url', image_url: { url } }, + ], }; + + const payload = { model, messages: [message] }; if (max_tokens != null) payload.max_tokens = max_tokens; + if (temperature != null) payload.temperature = temperature; - const response = await client.postJson(`${client.textBase}/openai`, payload, { timeoutMs }); - await raiseForStatus(response, 'vision'); - return await response.json(); + return payload; +} + +async function createDataUrl({ file, data, buffer, arrayBuffer, imageFormat }) { + const bytes = await resolveImageBytes({ file, data, buffer, arrayBuffer }); + if (!bytes) return null; + const fmt = imageFormat ?? guessImageFormat({ file }); + if (!fmt) { + throw new Error('imageFormat is required when providing raw image bytes'); + } + const base64 = base64FromArrayBuffer(bytes); + return `data:image/${fmt};base64,${base64}`; +} + +async function resolveImageBytes({ file, data, buffer, arrayBuffer }) { + if (file) return await arrayBufferFrom(file); + if (data) return await arrayBufferFrom(data); + if (buffer) return await arrayBufferFrom(buffer); + if (arrayBuffer) return await arrayBufferFrom(arrayBuffer); + return null; } function guessImageFormat({ file }) {