Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Libs/pollilib/javascript/polliLib/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const DEFAULTS = {
imageUrl: "https://image.pollinations.ai/models",
imagePromptBase: "https://image.pollinations.ai/prompt",
textPromptBase: "https://text.pollinations.ai",
timeoutMs: 10000,
timeoutMs: 60000,
minRequestIntervalMs: 3000,
retryInitialDelayMs: 500,
retryDelayStepMs: 100,
Expand Down Expand Up @@ -161,6 +161,15 @@ export class BaseClient {
return false;
}

_resolveTimeout(timeoutMs, fallbackMs = null) {
if (Number.isFinite(timeoutMs) && timeoutMs > 0) return timeoutMs;
const base = Number.isFinite(this.timeoutMs) && this.timeoutMs > 0 ? this.timeoutMs : null;
if (base != null) return base;
const fallback = Number.isFinite(fallbackMs) && fallbackMs > 0 ? fallbackMs : null;
if (fallback != null) return fallback;
return 60_000;
}

async _rateLimitedRequest(executor) {
const run = async () => {
let attempt = 0;
Expand Down
43 changes: 37 additions & 6 deletions Libs/pollilib/javascript/polliLib/chat.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
export const ChatMixin = (Base) => class extends Base {
async chat_completion(messages, { model = 'openai', seed = null, private_: priv = undefined, referrer = null, token = null, asJson = false, timeoutMs = 60_000 } = {}) {
async chat_completion(messages, options = {}) {
if (!Array.isArray(messages) || messages.length === 0) throw new Error('messages must be a non-empty list');
const {
model = 'openai',
private_: priv = undefined,
referrer = null,
token = null,
asJson = false,
timeoutMs,
} = options;
let seed = options.seed ?? null;
if (seed == null) seed = this._randomSeed();
const payload = { model, messages, seed };
if (priv !== undefined) payload.private = !!priv;
Expand All @@ -9,7 +18,7 @@ export const ChatMixin = (Base) => class extends Base {
payload.safe = false;
const url = `${this.textPromptBase}/${model}`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
const t = setTimeout(() => controller.abort(), this._resolveTimeout(timeoutMs, 60_000));
try {
const resp = await this.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
Expand All @@ -19,8 +28,17 @@ export const ChatMixin = (Base) => class extends Base {
} finally { clearTimeout(t); }
}

async *chat_completion_stream(messages, { model = 'openai', seed = null, private_: priv = undefined, referrer = null, token = null, timeoutMs = 300_000, yieldRawEvents = false } = {}) {
async *chat_completion_stream(messages, options = {}) {
if (!Array.isArray(messages) || messages.length === 0) throw new Error('messages must be a non-empty list');
const {
model = 'openai',
private_: priv = undefined,
referrer = null,
token = null,
timeoutMs,
yieldRawEvents = false,
} = options;
let seed = options.seed ?? null;
if (seed == null) seed = this._randomSeed();
const payload = { model, messages, seed, stream: true };
if (priv !== undefined) payload.private = !!priv;
Expand All @@ -29,7 +47,7 @@ export const ChatMixin = (Base) => class extends Base {
payload.safe = false;
const url = `${this.textPromptBase}/${model}`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
const t = setTimeout(() => controller.abort(), this._resolveTimeout(timeoutMs, 300_000));
try {
const resp = await this.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, body: JSON.stringify(payload), signal: controller.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
Expand All @@ -47,13 +65,26 @@ export const ChatMixin = (Base) => class extends Base {
} finally { clearTimeout(t); }
}

async chat_completion_tools(messages, { tools, functions = {}, tool_choice = 'auto', model = 'openai', seed = null, private_: priv = undefined, referrer = null, token = null, asJson = false, timeoutMs = 60_000, max_rounds = 1 } = {}) {
async chat_completion_tools(messages, options = {}) {
if (!Array.isArray(messages) || messages.length === 0) throw new Error('messages must be a non-empty list');
const {
tools,
functions = {},
tool_choice = 'auto',
model = 'openai',
private_: priv = undefined,
referrer = null,
token = null,
asJson = false,
timeoutMs,
max_rounds = 1,
} = options;
if (!Array.isArray(tools) || tools.length === 0) throw new Error('tools must be a non-empty list');
let seed = options.seed ?? null;
if (seed == null) seed = this._randomSeed();
const url = `${this.textPromptBase}/${model}`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
const t = setTimeout(() => controller.abort(), this._resolveTimeout(timeoutMs, 60_000));
try {
const history = [...messages];
let rounds = 0;
Expand Down
28 changes: 24 additions & 4 deletions Libs/pollilib/javascript/polliLib/feeds.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
export const FeedsMixin = (Base) => class extends Base {
async *image_feed_stream({ referrer = null, token = null, timeoutMs = 300_000, reconnect = false, retryDelayMs = 10_000, yieldRawEvents = false, includeBytes = false, includeDataUrl = false } = {}) {
async *image_feed_stream(options = {}) {
const {
referrer = null,
token = null,
timeoutMs,
reconnect = false,
retryDelayMs = 10_000,
yieldRawEvents = false,
includeBytes = false,
includeDataUrl = false,
} = options;
const feedUrl = new URL('https://image.pollinations.ai/feed');
if (referrer) feedUrl.searchParams.set('referrer', referrer);
if (token) feedUrl.searchParams.set('token', token);
const limit = this._resolveTimeout(timeoutMs, 300_000);

const connect = async function* (self) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || self.timeoutMs);
const t = setTimeout(() => controller.abort(), limit);
try {
const resp = await self.fetch(feedUrl, { method: 'GET', headers: { 'Accept': 'text/event-stream' }, signal: controller.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
Expand Down Expand Up @@ -51,13 +62,22 @@ export const FeedsMixin = (Base) => class extends Base {
}
}

async *text_feed_stream({ referrer = null, token = null, timeoutMs = 300_000, reconnect = false, retryDelayMs = 10_000, yieldRawEvents = false } = {}) {
async *text_feed_stream(options = {}) {
const {
referrer = null,
token = null,
timeoutMs,
reconnect = false,
retryDelayMs = 10_000,
yieldRawEvents = false,
} = options;
const feedUrl = new URL('https://text.pollinations.ai/feed');
if (referrer) feedUrl.searchParams.set('referrer', referrer);
if (token) feedUrl.searchParams.set('token', token);
const limit = this._resolveTimeout(timeoutMs, 300_000);
const connect = async function* (self) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || self.timeoutMs);
const t = setTimeout(() => controller.abort(), limit);
try {
const resp = await self.fetch(feedUrl, { method: 'GET', headers: { 'Accept': 'text/event-stream' }, signal: controller.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
Expand Down
69 changes: 39 additions & 30 deletions Libs/pollilib/javascript/polliLib/images.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,23 @@ import fs from 'node:fs';
import path from 'node:path';

export const ImagesMixin = (Base) => class extends Base {
async generate_image(prompt, {
width = 512,
height = 512,
model = 'flux',
seed = null,
nologo = true,
image = null,
referrer = null,
token = null,
timeoutMs = 300_000,
outPath = null,
chunkSize = 64 * 1024,
} = {}) {
async generate_image(prompt, options = {}) {
if (!prompt || !String(prompt).trim()) throw new Error('prompt must be a non-empty string');
let width = options.width ?? 512;
let height = options.height ?? 512;
const {
model = 'flux',
nologo = true,
image = null,
referrer = null,
token = null,
timeoutMs,
outPath = null,
chunkSize = 64 * 1024,
} = options;
width = Number(width); height = Number(height);
if (!(width > 0) || !(height > 0)) throw new Error('width and height must be positive integers');
let seed = options.seed ?? null;
if (seed == null) seed = this._randomSeed();
const params = new URLSearchParams({ width: String(width), height: String(height), seed: String(seed), model: String(model), nologo: nologo ? 'true' : 'false' });
params.set('safe', 'false');
Expand All @@ -28,7 +29,7 @@ export const ImagesMixin = (Base) => class extends Base {
const full = `${url}?${params}`;
const response = await this._rateLimitedRequest(async () => {
const controller = new AbortController();
const limit = timeoutMs ?? this.timeoutMs;
const limit = this._resolveTimeout(timeoutMs, 300_000);
const t = setTimeout(() => controller.abort(), limit);
try {
return await this.fetch(full, { method: 'GET', signal: controller.signal });
Expand All @@ -44,20 +45,21 @@ export const ImagesMixin = (Base) => class extends Base {
return Buffer.from(buf);
}

async save_image_timestamped(prompt, {
width = 512,
height = 512,
model = 'flux',
nologo = true,
image = null,
referrer = null,
token = null,
timeoutMs = 300_000,
imagesDir = null,
filenamePrefix = '',
filenameSuffix = '',
ext = 'jpeg',
} = {}) {
async save_image_timestamped(prompt, options = {}) {
const {
width = 512,
height = 512,
model = 'flux',
nologo = true,
image = null,
referrer = null,
token = null,
timeoutMs,
imagesDir = null,
filenamePrefix = '',
filenameSuffix = '',
ext = 'jpeg',
} = options;
imagesDir ||= path.join(process.cwd(), 'images');
await fs.promises.mkdir(imagesDir, { recursive: true });
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 15); // YYYYMMDDHHMMSS
Expand All @@ -68,13 +70,20 @@ export const ImagesMixin = (Base) => class extends Base {
return outPath;
}

async fetch_image(imageUrl, { referrer = null, token = null, timeoutMs = 120_000, outPath = null, chunkSize = 64 * 1024 } = {}) {
async fetch_image(imageUrl, options = {}) {
const {
referrer = null,
token = null,
timeoutMs,
outPath = null,
chunkSize = 64 * 1024,
} = options;
const u = new URL(imageUrl);
if (referrer) u.searchParams.set('referrer', referrer);
if (token) u.searchParams.set('token', token);
const response = await this._rateLimitedRequest(async () => {
const controller = new AbortController();
const limit = timeoutMs ?? this.timeoutMs;
const limit = this._resolveTimeout(timeoutMs, 120_000);
const t = setTimeout(() => controller.abort(), limit);
try {
return await this.fetch(u, { method: 'GET', signal: controller.signal });
Expand Down
12 changes: 10 additions & 2 deletions Libs/pollilib/javascript/polliLib/stt.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import fs from 'node:fs';

export const STTMixin = (Base) => class extends Base {
async transcribe_audio(audioPath, { question = 'Transcribe this audio', model = 'openai-audio', provider = 'openai', referrer = null, token = null, timeoutMs = 120_000 } = {}) {
async transcribe_audio(audioPath, options = {}) {
const {
question = 'Transcribe this audio',
model = 'openai-audio',
provider = 'openai',
referrer = null,
token = null,
timeoutMs,
} = options;
if (!fs.existsSync(audioPath)) throw new Error(`File not found: ${audioPath}`);
const ext = String(audioPath).split('.').pop().toLowerCase();
if (!['mp3','wav'].includes(ext)) return null;
Expand All @@ -19,7 +27,7 @@ export const STTMixin = (Base) => class extends Base {
const url = `${this.textPromptBase}/${provider}`;
const response = await this._rateLimitedRequest(async () => {
const controller = new AbortController();
const limit = timeoutMs ?? this.timeoutMs;
const limit = this._resolveTimeout(timeoutMs, 120_000);
const t = setTimeout(() => controller.abort(), limit);
try {
return await this.fetch(url, {
Expand Down
13 changes: 11 additions & 2 deletions Libs/pollilib/javascript/polliLib/text.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
export const TextMixin = (Base) => class extends Base {
async generate_text(prompt, { model = 'openai', seed = null, system = null, referrer = null, token = null, asJson = false, timeoutMs = 60_000 } = {}) {
async generate_text(prompt, options = {}) {
if (!prompt || !String(prompt).trim()) throw new Error('prompt must be a non-empty string');
const {
model = 'openai',
system = null,
referrer = null,
token = null,
asJson = false,
timeoutMs,
} = options;
let seed = options.seed ?? null;
if (seed == null) seed = this._randomSeed();
const url = new URL(this._textPromptUrl(String(prompt)));
url.searchParams.set('model', model);
Expand All @@ -12,7 +21,7 @@ export const TextMixin = (Base) => class extends Base {
if (token) url.searchParams.set('token', token);
const response = await this._rateLimitedRequest(async () => {
const controller = new AbortController();
const limit = timeoutMs ?? this.timeoutMs;
const limit = this._resolveTimeout(timeoutMs, 60_000);
const t = setTimeout(() => controller.abort(), limit);
try {
return await this.fetch(url, { method: 'GET', signal: controller.signal });
Expand Down
26 changes: 22 additions & 4 deletions Libs/pollilib/javascript/polliLib/vision.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import fs from 'node:fs';

export const VisionMixin = (Base) => class extends Base {
async analyze_image_url(imageUrl, { question = "What's in this image?", model = 'openai', max_tokens = 500, referrer = null, token = null, timeoutMs = 60_000, asJson = false } = {}) {
async analyze_image_url(imageUrl, options = {}) {
const {
question = "What's in this image?",
model = 'openai',
max_tokens = 500,
referrer = null,
token = null,
timeoutMs,
asJson = false,
} = options;
const payload = {
model,
messages: [ { role: 'user', content: [ { type: 'text', text: question }, { type: 'image_url', image_url: { url: imageUrl } } ] } ],
Expand All @@ -12,7 +21,7 @@ export const VisionMixin = (Base) => class extends Base {
payload.safe = false;
const url = `${this.textPromptBase}/${model}`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
const t = setTimeout(() => controller.abort(), this._resolveTimeout(timeoutMs, 60_000));
try {
const resp = await this.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
Expand All @@ -22,7 +31,16 @@ export const VisionMixin = (Base) => class extends Base {
} finally { clearTimeout(t); }
}

async analyze_image_file(imagePath, { question = "What's in this image?", model = 'openai', max_tokens = 500, referrer = null, token = null, timeoutMs = 60_000, asJson = false } = {}) {
async analyze_image_file(imagePath, options = {}) {
const {
question = "What's in this image?",
model = 'openai',
max_tokens = 500,
referrer = null,
token = null,
timeoutMs,
asJson = false,
} = options;
if (!fs.existsSync(imagePath)) throw new Error(`File not found: ${imagePath}`);
let ext = String(imagePath).split('.').pop().toLowerCase();
if (!['jpeg','jpg','png','gif','webp'].includes(ext)) ext = 'jpeg';
Expand All @@ -39,7 +57,7 @@ export const VisionMixin = (Base) => class extends Base {
payload.safe = false;
const url = `${this.textPromptBase}/${model}`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
const t = setTimeout(() => controller.abort(), this._resolveTimeout(timeoutMs, 60_000));
try {
const resp = await this.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
Expand Down
Loading