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
93 changes: 93 additions & 0 deletions Libs/pollilib/javascript/polliLib/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ const DEFAULTS = {
imagePromptBase: "https://image.pollinations.ai/prompt",
textPromptBase: "https://text.pollinations.ai",
timeoutMs: 10000,
minRequestIntervalMs: 3000,
retryInitialDelayMs: 500,
retryDelayStepMs: 100,
retryMaxDelayMs: 4000,
};
const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);

export class BaseClient {
constructor(opts = {}) {
Expand All @@ -18,6 +23,27 @@ export class BaseClient {
this.fetch = opts.fetch || (typeof fetch !== "undefined" ? fetch.bind(globalThis) : null);
if (!this.fetch) throw new Error("fetch is not available; provide opts.fetch");
this._modelsCache = new Map(); // kind -> list
this.minRequestIntervalMs = Number.isFinite(opts.minRequestIntervalMs)
? Math.max(0, opts.minRequestIntervalMs)
: DEFAULTS.minRequestIntervalMs;
this.retryInitialDelayMs = Number.isFinite(opts.retryInitialDelayMs)
? Math.max(0, opts.retryInitialDelayMs)
: DEFAULTS.retryInitialDelayMs;
this.retryDelayStepMs = Number.isFinite(opts.retryDelayStepMs)
? Math.max(0, opts.retryDelayStepMs)
: DEFAULTS.retryDelayStepMs;
this.retryMaxDelayMs = Number.isFinite(opts.retryMaxDelayMs)
? Math.max(this.retryInitialDelayMs, opts.retryMaxDelayMs)
: DEFAULTS.retryMaxDelayMs;
const steps = this.retryDelayStepMs > 0
? Math.floor(Math.max(0, this.retryMaxDelayMs - this.retryInitialDelayMs) / this.retryDelayStepMs)
: 0;
this._maxRetryAttempts = this.retryMaxDelayMs > 0 ? steps + 1 : 0;
this._sleepFn = typeof opts.sleep === "function"
? opts.sleep
: (ms) => new Promise((resolve) => setTimeout(resolve, ms));
this._lastSuccessAt = 0;
this._requestQueue = Promise.resolve();
}

async listModels(kind /* 'text' | 'image' */) {
Expand Down Expand Up @@ -109,5 +135,72 @@ export class BaseClient {
_textPromptUrl(prompt) {
return `${this.textPromptBase}/${encodeURIComponent(prompt)}`;
}

_retryDelayMs(attempt) {
if (attempt <= 0) return 0;
if (this.retryInitialDelayMs <= 0) return 0;
if (attempt === 1) return this.retryInitialDelayMs;
if (this.retryDelayStepMs <= 0) return Math.min(this.retryInitialDelayMs, this.retryMaxDelayMs);
const delay = this.retryInitialDelayMs + (attempt - 1) * this.retryDelayStepMs;
return Math.min(delay, this.retryMaxDelayMs);
}

async _sleep(ms) {
if (!(ms > 0)) return;
await this._sleepFn(ms);
}

_shouldRetryResponse(resp) {
return resp && RETRYABLE_STATUS.has(resp.status);
}

_isRetryableError(error) {
if (!error) return false;
if (error.retryable === true) return true;
if (typeof error.status === "number" && RETRYABLE_STATUS.has(error.status)) return true;
return false;
}

async _rateLimitedRequest(executor) {
const run = async () => {
let attempt = 0;
let lastError = null;
for (;;) {
if (attempt === 0) {
const waitMs = Math.max(0, this._lastSuccessAt + this.minRequestIntervalMs - Date.now());
if (waitMs > 0) await this._sleep(waitMs);
} else {
const delay = this._retryDelayMs(attempt);
if (delay > 0) await this._sleep(delay);
}
try {
const response = await executor(attempt);
if (this._shouldRetryResponse(response)) {
lastError = new Error(`HTTP ${response.status}`);
lastError.status = response.status;
try { response.body?.cancel?.(); } catch {}
attempt += 1;
if (attempt > this._maxRetryAttempts) throw lastError;
continue;
}
if (!response.ok) {
const err = new Error(`HTTP ${response.status}`);
err.status = response.status;
throw err;
}
this._lastSuccessAt = Date.now();
return response;
} catch (error) {
lastError = error;
if (!this._isRetryableError(error)) throw error;
attempt += 1;
if (attempt > this._maxRetryAttempts) throw lastError;
}
}
};
const next = this._requestQueue.then(run, run);
this._requestQueue = next.catch(() => {});
return next;
}
}

52 changes: 28 additions & 24 deletions Libs/pollilib/javascript/polliLib/images.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,22 @@ export const ImagesMixin = (Base) => class extends Base {
if (token) params.set('token', token);
const url = this._imagePromptUrl(String(prompt));
const full = `${url}?${params}`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
try {
const resp = await this.fetch(full, { method: 'GET', signal: controller.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (outPath) {
await streamToFile(resp, outPath, chunkSize);
return outPath;
const response = await this._rateLimitedRequest(async () => {
const controller = new AbortController();
const limit = timeoutMs ?? this.timeoutMs;
const t = setTimeout(() => controller.abort(), limit);
try {
return await this.fetch(full, { method: 'GET', signal: controller.signal });
} finally {
clearTimeout(t);
}
const buf = await resp.arrayBuffer();
return Buffer.from(buf);
} finally {
clearTimeout(t);
});
if (outPath) {
await streamToFile(response, outPath, chunkSize);
return outPath;
}
const buf = await response.arrayBuffer();
return Buffer.from(buf);
}

async save_image_timestamped(prompt, {
Expand Down Expand Up @@ -70,20 +72,22 @@ export const ImagesMixin = (Base) => class extends Base {
const u = new URL(imageUrl);
if (referrer) u.searchParams.set('referrer', referrer);
if (token) u.searchParams.set('token', token);
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
try {
const resp = await this.fetch(u, { method: 'GET', signal: controller.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (outPath) {
await streamToFile(resp, outPath, chunkSize);
return outPath;
const response = await this._rateLimitedRequest(async () => {
const controller = new AbortController();
const limit = timeoutMs ?? this.timeoutMs;
const t = setTimeout(() => controller.abort(), limit);
try {
return await this.fetch(u, { method: 'GET', signal: controller.signal });
} finally {
clearTimeout(t);
}
const buf = await resp.arrayBuffer();
return Buffer.from(buf);
} finally {
clearTimeout(t);
});
if (outPath) {
await streamToFile(response, outPath, chunkSize);
return outPath;
}
const buf = await response.arrayBuffer();
return Buffer.from(buf);
}
};

Expand Down
25 changes: 17 additions & 8 deletions Libs/pollilib/javascript/polliLib/stt.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,23 @@ export const STTMixin = (Base) => class extends Base {
if (token) payload.token = token;
payload.safe = false;
const url = `${this.textPromptBase}/${provider}`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
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}`);
const json = await resp.json();
return json?.choices?.[0]?.message?.content;
} finally { clearTimeout(t); }
const response = await this._rateLimitedRequest(async () => {
const controller = new AbortController();
const limit = timeoutMs ?? this.timeoutMs;
const t = setTimeout(() => controller.abort(), limit);
try {
return await this.fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
} finally {
clearTimeout(t);
}
});
const json = await response.json();
return json?.choices?.[0]?.message?.content;
}
};

24 changes: 13 additions & 11 deletions Libs/pollilib/javascript/polliLib/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,20 @@ export const TextMixin = (Base) => class extends Base {
if (system) url.searchParams.set('system', system);
if (referrer) url.searchParams.set('referrer', referrer);
if (token) url.searchParams.set('token', token);
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs);
try {
const resp = await this.fetch(url, { method: 'GET', signal: controller.signal });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (asJson) {
const text = await resp.text();
try { return JSON.parse(text); } catch { return text; }
const response = await this._rateLimitedRequest(async () => {
const controller = new AbortController();
const limit = timeoutMs ?? this.timeoutMs;
const t = setTimeout(() => controller.abort(), limit);
try {
return await this.fetch(url, { method: 'GET', signal: controller.signal });
} finally {
clearTimeout(t);
}
return await resp.text();
} finally {
clearTimeout(t);
});
if (asJson) {
const text = await response.text();
try { return JSON.parse(text); } catch { return text; }
}
return await response.text();
}
};
34 changes: 34 additions & 0 deletions Libs/pollilib/javascript/tests/test_text_chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,37 @@ test('chat_completion_tools two-step', async () => {
assert.equal(secondBody.safe, false);
});

test('generate_text enforces minimum 3s spacing between successes', async () => {
const seq = new SeqFetch([
new FakeResponse({ text: 'first' }),
new FakeResponse({ text: 'second' }),
]);
const sleeps = [];
const c = new PolliClient({
fetch: seq.fetch.bind(seq),
sleep: async (ms) => { sleeps.push(ms); },
});
const first = await c.generate_text('hello');
assert.equal(first, 'first');
const second = await c.generate_text('world');
assert.equal(second, 'second');
assert.ok(sleeps.some((ms) => ms >= 2990), `expected >=2990ms wait, saw ${sleeps}`);
});

test('generate_text retries quickly when hitting rate limits', async () => {
const seq = new SeqFetch([
new FakeResponse({ status: 429, text: 'limit' }),
new FakeResponse({ status: 503, text: 'busy' }),
new FakeResponse({ text: 'ok' }),
]);
const sleeps = [];
const c = new PolliClient({
fetch: seq.fetch.bind(seq),
sleep: async (ms) => { sleeps.push(ms); },
});
const result = await c.generate_text('retry please');
assert.equal(result, 'ok');
const rounded = sleeps.map((ms) => Math.round(ms));
assert.deepEqual(rounded.slice(0, 2), [500, 600]);
});

50 changes: 49 additions & 1 deletion Libs/pollilib/python/polliLib/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import annotations

from functools import lru_cache
from typing import Any, Dict, Iterable, List, Literal, Optional, TypedDict
import threading
import time
from typing import Any, Callable, Dict, Iterable, List, Literal, Optional, TypedDict
import requests

ModelType = Literal["text", "image"]
Expand Down Expand Up @@ -33,13 +35,33 @@ def __init__(
text_prompt_base: str = "https://text.pollinations.ai",
timeout: float = 10.0,
session: Optional[requests.Session] = None,
min_request_interval: float = 3.0,
retry_initial_delay: float = 0.5,
retry_delay_step: float = 0.1,
retry_max_delay: float = 4.0,
sleep: Optional[Callable[[float], None]] = None,
) -> None:
self.text_url = text_url
self.image_url = image_url
self.image_prompt_base = image_prompt_base
self.text_prompt_base = text_prompt_base
self.timeout = timeout
self.session = session or requests.Session()
self.min_request_interval = max(0.0, float(min_request_interval))
self.retry_initial_delay = max(0.0, float(retry_initial_delay))
self.retry_delay_step = max(0.0, float(retry_delay_step))
self.retry_max_delay = max(self.retry_initial_delay, float(retry_max_delay))
if self.retry_delay_step > 0 and self.retry_max_delay > 0 and self.retry_initial_delay > 0:
steps = int(max(0.0, (self.retry_max_delay - self.retry_initial_delay)) / self.retry_delay_step)
self._max_retry_attempts = steps + 1
elif self.retry_initial_delay > 0 and self.retry_max_delay > 0:
self._max_retry_attempts = 1
else:
self._max_retry_attempts = 0
self._sleep = sleep or time.sleep
self._last_success_ts = 0.0
self._request_lock = threading.Lock()
self._retryable_statuses = {429, 502, 503, 504}

@lru_cache(maxsize=4)
def list_models(self, kind: ModelType) -> List[Model]:
Expand Down Expand Up @@ -134,3 +156,29 @@ def _text_prompt_url(self, prompt: str) -> str:
from urllib.parse import quote
return f"{self.text_prompt_base}/{quote(prompt)}"

def _retry_delay(self, attempt: int) -> float:
if attempt <= 0 or self.retry_initial_delay <= 0:
return 0.0
if attempt == 1 or self.retry_delay_step <= 0:
return min(self.retry_initial_delay, self.retry_max_delay)
delay = self.retry_initial_delay + (attempt - 1) * self.retry_delay_step
return min(delay, self.retry_max_delay)

def _can_retry(self, attempt: int) -> bool:
return attempt <= self._max_retry_attempts

def _wait_before_attempt(self, attempt: int) -> None:
now = time.monotonic()
if attempt == 0:
wait_for = (self._last_success_ts + self.min_request_interval) - now
else:
wait_for = self._retry_delay(attempt)
if wait_for > 0:
self._sleep(wait_for)

def _mark_success(self) -> None:
self._last_success_ts = time.monotonic()

def _should_retry_status(self, status: int) -> bool:
return status in self._retryable_statuses

Loading