diff --git a/Libs/pollilib/javascript/polliLib/base.js b/Libs/pollilib/javascript/polliLib/base.js index e3eebbe..a4b3def 100644 --- a/Libs/pollilib/javascript/polliLib/base.js +++ b/Libs/pollilib/javascript/polliLib/base.js @@ -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, @@ -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; diff --git a/Libs/pollilib/javascript/polliLib/chat.js b/Libs/pollilib/javascript/polliLib/chat.js index 5dc6186..ad723c6 100644 --- a/Libs/pollilib/javascript/polliLib/chat.js +++ b/Libs/pollilib/javascript/polliLib/chat.js @@ -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; @@ -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}`); @@ -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; @@ -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}`); @@ -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; diff --git a/Libs/pollilib/javascript/polliLib/feeds.js b/Libs/pollilib/javascript/polliLib/feeds.js index 1493dab..9dd1990 100644 --- a/Libs/pollilib/javascript/polliLib/feeds.js +++ b/Libs/pollilib/javascript/polliLib/feeds.js @@ -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}`); @@ -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}`); diff --git a/Libs/pollilib/javascript/polliLib/images.js b/Libs/pollilib/javascript/polliLib/images.js index 35305cc..84b2b71 100644 --- a/Libs/pollilib/javascript/polliLib/images.js +++ b/Libs/pollilib/javascript/polliLib/images.js @@ -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'); @@ -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 }); @@ -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 @@ -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 }); diff --git a/Libs/pollilib/javascript/polliLib/stt.js b/Libs/pollilib/javascript/polliLib/stt.js index d751e94..b117bef 100644 --- a/Libs/pollilib/javascript/polliLib/stt.js +++ b/Libs/pollilib/javascript/polliLib/stt.js @@ -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; @@ -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, { diff --git a/Libs/pollilib/javascript/polliLib/text.js b/Libs/pollilib/javascript/polliLib/text.js index 10ef270..35f00c8 100644 --- a/Libs/pollilib/javascript/polliLib/text.js +++ b/Libs/pollilib/javascript/polliLib/text.js @@ -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); @@ -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 }); diff --git a/Libs/pollilib/javascript/polliLib/vision.js b/Libs/pollilib/javascript/polliLib/vision.js index 72c0305..fbc9f28 100644 --- a/Libs/pollilib/javascript/polliLib/vision.js +++ b/Libs/pollilib/javascript/polliLib/vision.js @@ -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 } } ] } ], @@ -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}`); @@ -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'; @@ -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}`); diff --git a/Libs/pollilib/javascript/tests/test_text_chat.js b/Libs/pollilib/javascript/tests/test_text_chat.js index 6b6f6aa..dd4259e 100644 --- a/Libs/pollilib/javascript/tests/test_text_chat.js +++ b/Libs/pollilib/javascript/tests/test_text_chat.js @@ -100,3 +100,67 @@ test('generate_text retries quickly when hitting rate limits', async () => { assert.deepEqual(rounded.slice(0, 2), [500, 600]); }); +test('generate_text uses client timeout when not specified per-call', async () => { + let aborts = 0; + const fetch = (url, opts = {}) => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timeout exceeded')), 500); + const onAbort = () => { + clearTimeout(timer); + aborts += 1; + const err = new Error('aborted'); + err.name = 'AbortError'; + reject(err); + }; + if (opts.signal) { + if (opts.signal.aborted) { onAbort(); return; } + opts.signal.addEventListener('abort', onAbort, { once: true }); + } + }); + const c = new PolliClient({ + fetch, + timeoutMs: 25, + minRequestIntervalMs: 0, + retryInitialDelayMs: 0, + retryDelayStepMs: 0, + retryMaxDelayMs: 0, + sleep: async () => {}, + }); + const start = Date.now(); + await assert.rejects(() => c.generate_text('hi'), (err) => /abort/i.test(err.name) || /abort/i.test(err.message)); + const elapsed = Date.now() - start; + assert.ok(elapsed >= 20 && elapsed < 200, `expected abort near 25ms, observed ${elapsed}`); + assert.equal(aborts, 1); +}); + +test('generate_text allows explicit timeout override per request', async () => { + let aborts = 0; + const fetch = (url, opts = {}) => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timeout exceeded')), 500); + const onAbort = () => { + clearTimeout(timer); + aborts += 1; + const err = new Error('aborted'); + err.name = 'AbortError'; + reject(err); + }; + if (opts.signal) { + if (opts.signal.aborted) { onAbort(); return; } + opts.signal.addEventListener('abort', onAbort, { once: true }); + } + }); + const c = new PolliClient({ + fetch, + timeoutMs: 250, + minRequestIntervalMs: 0, + retryInitialDelayMs: 0, + retryDelayStepMs: 0, + retryMaxDelayMs: 0, + sleep: async () => {}, + }); + const start = Date.now(); + await assert.rejects(() => c.generate_text('hi', { timeoutMs: 40 }), (err) => /abort/i.test(err.name) || /abort/i.test(err.message)); + const elapsed = Date.now() - start; + assert.ok(elapsed >= 30 && elapsed < 200, `expected abort near 40ms, observed ${elapsed}`); + assert.equal(aborts, 1); +}); + diff --git a/Libs/pollilib/python/polliLib/__init__.py b/Libs/pollilib/python/polliLib/__init__.py index 0fc54a6..2e14dd4 100644 --- a/Libs/pollilib/python/polliLib/__init__.py +++ b/Libs/pollilib/python/polliLib/__init__.py @@ -79,7 +79,7 @@ def generate_image( image: Optional[str] = None, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 300.0, + timeout: Optional[float] = None, out_path: Optional[str] = None, chunk_size: int = 1024 * 64, ) -> bytes | str: @@ -109,7 +109,7 @@ def save_image_timestamped( image: Optional[str] = None, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 300.0, + timeout: Optional[float] = None, images_dir: Optional[str] = None, filename_prefix: str = "", filename_suffix: str = "", @@ -137,7 +137,7 @@ def fetch_image( *, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 120.0, + timeout: Optional[float] = None, out_path: Optional[str] = None, chunk_size: int = 1024 * 64, ) -> bytes | str: @@ -160,7 +160,7 @@ def generate_text( referrer: Optional[str] = None, token: Optional[str] = None, as_json: bool = False, - timeout: Optional[float] = 60.0, + timeout: Optional[float] = None, ): return _client().generate_text( prompt, @@ -183,7 +183,7 @@ def chat_completion( referrer: Optional[str] = None, token: Optional[str] = None, as_json: bool = False, - timeout: Optional[float] = 60.0, + timeout: Optional[float] = None, ): return _client().chat_completion( messages, @@ -205,7 +205,7 @@ def chat_completion_stream( private: Optional[bool] = None, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 300.0, + timeout: Optional[float] = None, yield_raw_events: bool = False, ): return _client().chat_completion_stream( @@ -232,7 +232,7 @@ def chat_completion_tools( referrer: Optional[str] = None, token: Optional[str] = None, as_json: bool = False, - timeout: Optional[float] = 60.0, + timeout: Optional[float] = None, max_rounds: int = 1, ): return _client().chat_completion_tools( @@ -259,7 +259,7 @@ def transcribe_audio( provider: str = "openai", referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 120.0, + timeout: Optional[float] = None, ): return _client().transcribe_audio( audio_path, @@ -280,7 +280,7 @@ def analyze_image_url( max_tokens: Optional[int] = 500, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 60.0, + timeout: Optional[float] = None, as_json: bool = False, ): return _client().analyze_image_url( @@ -303,7 +303,7 @@ def analyze_image_file( max_tokens: Optional[int] = 500, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 60.0, + timeout: Optional[float] = None, as_json: bool = False, ): return _client().analyze_image_file( @@ -322,7 +322,7 @@ def image_feed_stream( *, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 300.0, + timeout: Optional[float] = None, reconnect: bool = False, retry_delay: float = 10.0, yield_raw_events: bool = False, @@ -345,7 +345,7 @@ def text_feed_stream( *, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 300.0, + timeout: Optional[float] = None, reconnect: bool = False, retry_delay: float = 10.0, yield_raw_events: bool = False, diff --git a/Libs/pollilib/python/polliLib/base.py b/Libs/pollilib/python/polliLib/base.py index 9b01540..03c7c30 100644 --- a/Libs/pollilib/python/polliLib/base.py +++ b/Libs/pollilib/python/polliLib/base.py @@ -33,7 +33,7 @@ def __init__( image_url: str = "https://image.pollinations.ai/models", image_prompt_base: str = "https://image.pollinations.ai/prompt", text_prompt_base: str = "https://text.pollinations.ai", - timeout: float = 10.0, + timeout: float = 60.0, session: Optional[requests.Session] = None, min_request_interval: float = 3.0, retry_initial_delay: float = 0.5, @@ -182,3 +182,33 @@ def _mark_success(self) -> None: def _should_retry_status(self, status: int) -> bool: return status in self._retryable_statuses + def _resolve_timeout(self, timeout: Optional[float], fallback: Optional[float]) -> float: + if timeout is not None: + try: + timeout_val = float(timeout) + except (TypeError, ValueError): + timeout_val = None + else: + if timeout_val > 0: + return timeout_val + base_val: Optional[float] + try: + base_val = float(self.timeout) + if base_val <= 0: + base_val = None + except (TypeError, ValueError): + base_val = None + fallback_val: Optional[float] = None + if fallback is not None: + try: + fallback_candidate = float(fallback) + if fallback_candidate > 0: + fallback_val = fallback_candidate + except (TypeError, ValueError): + fallback_val = None + if base_val is not None: + return base_val + if fallback_val is not None: + return fallback_val + return 60.0 + diff --git a/Libs/pollilib/python/polliLib/chat.py b/Libs/pollilib/python/polliLib/chat.py index b9dfad5..67fd0dc 100644 --- a/Libs/pollilib/python/polliLib/chat.py +++ b/Libs/pollilib/python/polliLib/chat.py @@ -14,7 +14,7 @@ def chat_completion( referrer: Optional[str] = None, token: Optional[str] = None, as_json: bool = False, - timeout: Optional[float] = 60.0, + timeout: Optional[float] = None, ) -> Any: if not isinstance(messages, list) or not messages: raise ValueError("messages must be a non-empty list of {role, content} dicts") @@ -33,7 +33,7 @@ def chat_completion( payload["token"] = token payload["safe"] = False url = f"{self.text_prompt_base}/{model}" - eff_timeout = timeout if timeout is not None else max(self.timeout, 10.0) + eff_timeout = self._resolve_timeout(timeout, 60.0) headers = {"Content-Type": "application/json"} resp = self.session.post(url, headers=headers, json=payload, timeout=eff_timeout) resp.raise_for_status() @@ -58,7 +58,7 @@ def chat_completion_stream( private: Optional[bool] = None, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 300.0, + timeout: Optional[float] = None, yield_raw_events: bool = False, ) -> Iterator[str]: if not isinstance(messages, list) or not messages: @@ -79,7 +79,7 @@ def chat_completion_stream( payload["token"] = token payload["safe"] = False url = f"{self.text_prompt_base}/{model}" - eff_timeout = timeout if timeout is not None else max(self.timeout, 60.0) + eff_timeout = self._resolve_timeout(timeout, 300.0) headers = { "Content-Type": "application/json", "Accept": "text/event-stream", @@ -131,7 +131,7 @@ def chat_completion_tools( referrer: Optional[str] = None, token: Optional[str] = None, as_json: bool = False, - timeout: Optional[float] = 60.0, + timeout: Optional[float] = None, max_rounds: int = 1, ) -> Any: if not isinstance(messages, list) or not messages: @@ -142,7 +142,7 @@ def chat_completion_tools( seed = self._random_seed() url = f"{self.text_prompt_base}/{model}" headers = {"Content-Type": "application/json"} - eff_timeout = timeout if timeout is not None else max(self.timeout, 10.0) + eff_timeout = self._resolve_timeout(timeout, 60.0) history: List[Dict[str, Any]] = list(messages) rounds = 0 while True: diff --git a/Libs/pollilib/python/polliLib/feeds.py b/Libs/pollilib/python/polliLib/feeds.py index e639de1..8851b5e 100644 --- a/Libs/pollilib/python/polliLib/feeds.py +++ b/Libs/pollilib/python/polliLib/feeds.py @@ -9,7 +9,7 @@ def image_feed_stream( *, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 300.0, + timeout: Optional[float] = None, reconnect: bool = False, retry_delay: float = 10.0, yield_raw_events: bool = False, @@ -24,6 +24,8 @@ def image_feed_stream( """ feed_url = "https://image.pollinations.ai/feed" + eff_timeout = self._resolve_timeout(timeout, 300.0) + def _connect() -> Iterator[Any]: params: Dict[str, Any] = {} if referrer: @@ -31,7 +33,7 @@ def _connect() -> Iterator[Any]: if token: params["token"] = token headers = {"Accept": "text/event-stream"} - with self.session.get(feed_url, params=params, headers=headers, stream=True, timeout=timeout or self.timeout) as resp: + with self.session.get(feed_url, params=params, headers=headers, stream=True, timeout=eff_timeout) as resp: resp.raise_for_status() for raw in resp.iter_lines(decode_unicode=True): if not raw: @@ -58,7 +60,7 @@ def _connect() -> Iterator[Any]: if include_data_url or include_bytes: img_url = ev.get("imageURL") or ev.get("image_url") if img_url: - r = self.session.get(img_url, timeout=timeout or self.timeout) + r = self.session.get(img_url, timeout=eff_timeout) r.raise_for_status() content = r.content if include_data_url: @@ -89,13 +91,15 @@ def text_feed_stream( *, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 300.0, + timeout: Optional[float] = None, reconnect: bool = False, retry_delay: float = 10.0, yield_raw_events: bool = False, ) -> Iterator[Any]: feed_url = "https://text.pollinations.ai/feed" + eff_timeout = self._resolve_timeout(timeout, 300.0) + def _connect() -> Iterator[Any]: params: Dict[str, Any] = {} if referrer: @@ -103,7 +107,7 @@ def _connect() -> Iterator[Any]: if token: params["token"] = token headers = {"Accept": "text/event-stream"} - with self.session.get(feed_url, params=params, headers=headers, stream=True, timeout=timeout or self.timeout) as resp: + with self.session.get(feed_url, params=params, headers=headers, stream=True, timeout=eff_timeout) as resp: resp.raise_for_status() for raw in resp.iter_lines(decode_unicode=True): if not raw: diff --git a/Libs/pollilib/python/polliLib/images.py b/Libs/pollilib/python/polliLib/images.py index d8f9522..ddaf251 100644 --- a/Libs/pollilib/python/polliLib/images.py +++ b/Libs/pollilib/python/polliLib/images.py @@ -16,7 +16,7 @@ def generate_image( image: Optional[str] = None, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 300.0, + timeout: Optional[float] = None, out_path: Optional[str] = None, chunk_size: int = 1024 * 64, ) -> bytes | str: @@ -44,7 +44,7 @@ def generate_image( params["token"] = token url = self._image_prompt_url(prompt) - eff_timeout = timeout if timeout is not None else max(self.timeout, 60.0) + eff_timeout = self._resolve_timeout(timeout, 300.0) attempt = 0 stream = bool(out_path) response = None @@ -88,7 +88,7 @@ def save_image_timestamped( image: Optional[str] = None, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 300.0, + timeout: Optional[float] = None, images_dir: Optional[str] = None, filename_prefix: str = "", filename_suffix: str = "", @@ -123,7 +123,7 @@ def fetch_image( *, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 120.0, + timeout: Optional[float] = None, out_path: Optional[str] = None, chunk_size: int = 1024 * 64, ) -> bytes | str: @@ -138,7 +138,8 @@ def fetch_image( while True: with self._request_lock: self._wait_before_attempt(attempt) - resp = self.session.get(image_url, params=params, timeout=timeout or self.timeout, stream=stream) + eff_timeout = self._resolve_timeout(timeout, 120.0) + resp = self.session.get(image_url, params=params, timeout=eff_timeout, stream=stream) if self._should_retry_status(resp.status_code): if not self._can_retry(attempt + 1): resp.raise_for_status() diff --git a/Libs/pollilib/python/polliLib/stt.py b/Libs/pollilib/python/polliLib/stt.py index aff01c2..95c949f 100644 --- a/Libs/pollilib/python/polliLib/stt.py +++ b/Libs/pollilib/python/polliLib/stt.py @@ -13,7 +13,7 @@ def transcribe_audio( provider: str = "openai", referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 120.0, + timeout: Optional[float] = None, ) -> Optional[str]: import os, base64 if not os.path.exists(audio_path): @@ -44,10 +44,11 @@ def transcribe_audio( headers = {"Content-Type": "application/json"} attempt = 0 response = None + eff_timeout = self._resolve_timeout(timeout, 120.0) while True: with self._request_lock: self._wait_before_attempt(attempt) - resp = self.session.post(url, headers=headers, json=payload, timeout=timeout or self.timeout) + resp = self.session.post(url, headers=headers, json=payload, timeout=eff_timeout) if self._should_retry_status(resp.status_code): if not self._can_retry(attempt + 1): resp.raise_for_status() diff --git a/Libs/pollilib/python/polliLib/text.py b/Libs/pollilib/python/polliLib/text.py index ed54a25..ee373bd 100644 --- a/Libs/pollilib/python/polliLib/text.py +++ b/Libs/pollilib/python/polliLib/text.py @@ -14,7 +14,7 @@ def generate_text( referrer: Optional[str] = None, token: Optional[str] = None, as_json: bool = False, - timeout: Optional[float] = 60.0, + timeout: Optional[float] = None, ) -> Any: if not isinstance(prompt, str) or not prompt.strip(): raise ValueError("prompt must be a non-empty string") @@ -34,7 +34,7 @@ def generate_text( if token: params["token"] = token url = self._text_prompt_url(prompt) - eff_timeout = timeout if timeout is not None else max(self.timeout, 10.0) + eff_timeout = self._resolve_timeout(timeout, 60.0) attempt = 0 response = None while True: diff --git a/Libs/pollilib/python/polliLib/vision.py b/Libs/pollilib/python/polliLib/vision.py index aebf454..37467bc 100644 --- a/Libs/pollilib/python/polliLib/vision.py +++ b/Libs/pollilib/python/polliLib/vision.py @@ -13,7 +13,7 @@ def analyze_image_url( max_tokens: Optional[int] = 500, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 60.0, + timeout: Optional[float] = None, as_json: bool = False, ) -> Any: payload: Dict[str, Any] = { @@ -37,7 +37,8 @@ def analyze_image_url( payload["safe"] = False url = f"{self.text_prompt_base}/{model}" headers = {"Content-Type": "application/json"} - resp = self.session.post(url, headers=headers, json=payload, timeout=timeout or self.timeout) + eff_timeout = self._resolve_timeout(timeout, 60.0) + resp = self.session.post(url, headers=headers, json=payload, timeout=eff_timeout) resp.raise_for_status() data = resp.json() if as_json: @@ -53,7 +54,7 @@ def analyze_image_file( max_tokens: Optional[int] = 500, referrer: Optional[str] = None, token: Optional[str] = None, - timeout: Optional[float] = 60.0, + timeout: Optional[float] = None, as_json: bool = False, ) -> Any: import os, base64 @@ -86,7 +87,8 @@ def analyze_image_file( payload["safe"] = False url = f"{self.text_prompt_base}/{model}" headers = {"Content-Type": "application/json"} - resp = self.session.post(url, headers=headers, json=payload, timeout=timeout or self.timeout) + eff_timeout = self._resolve_timeout(timeout, 60.0) + resp = self.session.post(url, headers=headers, json=payload, timeout=eff_timeout) resp.raise_for_status() data = resp.json() if as_json: diff --git a/Libs/pollilib/python/tests/test_text_chat.py b/Libs/pollilib/python/tests/test_text_chat.py index fd282d3..40eaf18 100644 --- a/Libs/pollilib/python/tests/test_text_chat.py +++ b/Libs/pollilib/python/tests/test_text_chat.py @@ -160,3 +160,29 @@ def get(self, url, **kw): rounded = [round(delay, 1) for delay in sleeps[:2]] assert rounded == [0.5, 0.6] + +def test_generate_text_uses_client_timeout_when_unspecified(): + class TimeoutSession(FakeSession): + def get(self, url, **kw): + self.last_get = (url, kw) + return FakeResponse(text="ok") + + session = TimeoutSession() + c = PolliClient(session=session, timeout=120.0, min_request_interval=0.0) + assert c.generate_text("hello") == "ok" + assert session.last_get[1]["timeout"] == 120.0 + + +def test_generate_text_respects_explicit_timeout_override(): + class TimeoutSession(FakeSession): + def get(self, url, **kw): + self.last_get = (url, kw) + return FakeResponse(text="ok") + + session = TimeoutSession() + c = PolliClient(session=session, timeout=200.0, min_request_interval=0.0) + assert c.generate_text("first") == "ok" + assert session.last_get[1]["timeout"] == 200.0 + assert c.generate_text("second", timeout=45.0) == "ok" + assert session.last_get[1]["timeout"] == 45.0 +