@danhachuel/thunderbolt 0.3.41 → 0.3.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,457 @@
1
+ """Routing, retry and rate limiting shared by text, image and video providers.
2
+
3
+ The module deliberately stores only redacted provider metadata. API keys are used
4
+ only in memory to derive a one-way bucket fingerprint and are never persisted.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import time
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any, Callable, Iterable, Mapping
18
+ from urllib.parse import urlparse
19
+
20
+ import requests
21
+
22
+ from .llm_providers import (
23
+ LLM_ACTIVE_CARD_KEY,
24
+ ensure_llm_provider_cards,
25
+ normalize_llm_card,
26
+ provider_definition,
27
+ )
28
+ from .storage import STORAGE, ensure_storage
29
+
30
+
31
+ POOL_LLM = "llm_text"
32
+ POOL_IMAGE = "image"
33
+ POOL_VIDEO = "video"
34
+ POOLS = (POOL_LLM, POOL_IMAGE, POOL_VIDEO)
35
+ RATE_LIMIT_FILENAME = "llm_rate_limit.json"
36
+ ATTEMPTS_FILENAME = "provider_attempts.json"
37
+ COOLDOWNS_FILENAME = "provider_cooldowns.json"
38
+ DEFAULT_RPM_LIMIT = 40
39
+ DEFAULT_RPM_WINDOW_SECONDS = 60
40
+ DEFAULT_MAX_ATTEMPTS = 3
41
+ DEFAULT_COOLDOWN_SECONDS = 120
42
+ _LOCK_WAIT_SECONDS = 5.0
43
+ _STALE_LOCK_SECONDS = 45.0
44
+
45
+
46
+ class ProviderRoutingError(RuntimeError):
47
+ """Raised when no provider in a pool can complete a request."""
48
+
49
+ def __init__(self, message: str, *, attempts: list[dict[str, Any]] | None = None) -> None:
50
+ super().__init__(message)
51
+ self.attempts = attempts or []
52
+
53
+
54
+ class ProviderCallError(RuntimeError):
55
+ """A provider response or transport failure with a safe retry category."""
56
+
57
+ def __init__(
58
+ self,
59
+ message: str,
60
+ *,
61
+ status_code: int | None = None,
62
+ category: str = "unknown",
63
+ retryable: bool = False,
64
+ retry_after: float | None = None,
65
+ ) -> None:
66
+ super().__init__(message)
67
+ self.status_code = status_code
68
+ self.category = category
69
+ self.retryable = retryable
70
+ self.retry_after = retry_after
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class RoutedResponse:
75
+ card: dict[str, Any]
76
+ payload: dict[str, Any]
77
+ attempts: tuple[dict[str, Any], ...]
78
+
79
+
80
+ def _now() -> float:
81
+ return time.time()
82
+
83
+
84
+ def _iso(timestamp: float | None = None) -> str:
85
+ return datetime.fromtimestamp(timestamp or _now(), timezone.utc).isoformat()
86
+
87
+
88
+ def _safe_host(value: Any) -> str:
89
+ try:
90
+ return urlparse(str(value or "")).netloc or ""
91
+ except ValueError:
92
+ return ""
93
+
94
+
95
+ def _redacted_card_metadata(card: Mapping[str, Any]) -> dict[str, Any]:
96
+ return {
97
+ "id": str(card.get("id") or ""),
98
+ "provider": str(card.get("provider") or ""),
99
+ "model": str(card.get("model") or ""),
100
+ "base_url_host": _safe_host(card.get("base_url")),
101
+ }
102
+
103
+
104
+ def is_nvidia_nim_card(card: Mapping[str, Any]) -> bool:
105
+ """Identify NIM by provider or endpoint, not by the generic OpenAI label alone."""
106
+ provider = str(card.get("provider") or "").strip().lower()
107
+ host = _safe_host(card.get("base_url")).lower()
108
+ return provider in {"nvidia", "nvidia_nim"} or host == "integrate.api.nvidia.com"
109
+
110
+
111
+ def _card_fingerprint(card: Mapping[str, Any]) -> str:
112
+ secret = str(card.get("api_key") or "").strip()
113
+ material = "|".join(
114
+ (
115
+ str(card.get("provider") or "").strip().lower(),
116
+ str(card.get("id") or "").strip(),
117
+ str(card.get("base_url") or "").strip().lower(),
118
+ str(card.get("model") or "").strip(),
119
+ secret,
120
+ )
121
+ )
122
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
123
+
124
+
125
+ @contextmanager
126
+ def _exclusive_lock(path: Path):
127
+ """Small cross-process lock for the local JSON rate-limit state."""
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ deadline = _now() + _LOCK_WAIT_SECONDS
130
+ descriptor: int | None = None
131
+ while descriptor is None:
132
+ try:
133
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
134
+ except FileExistsError:
135
+ try:
136
+ if _now() - path.stat().st_mtime > _STALE_LOCK_SECONDS:
137
+ path.unlink(missing_ok=True)
138
+ continue
139
+ except OSError:
140
+ pass
141
+ if _now() >= deadline:
142
+ raise ProviderRoutingError("Não foi possível reservar o estado de routing local.")
143
+ time.sleep(0.02)
144
+ try:
145
+ os.write(descriptor, str(os.getpid()).encode("ascii"))
146
+ finally:
147
+ os.close(descriptor)
148
+ try:
149
+ yield
150
+ finally:
151
+ path.unlink(missing_ok=True)
152
+
153
+
154
+ def _load_state(path: Path, default: Any) -> Any:
155
+ try:
156
+ return json.loads(path.read_text(encoding="utf-8"))
157
+ except (OSError, json.JSONDecodeError):
158
+ return default
159
+
160
+
161
+ def _save_state(path: Path, value: Any) -> None:
162
+ path.parent.mkdir(parents=True, exist_ok=True)
163
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
164
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
165
+ os.replace(temporary, path)
166
+
167
+
168
+ def nvidia_rpm_enabled(settings: Mapping[str, Any]) -> bool:
169
+ return bool(settings.get("llm_rpm_limit_enabled", False))
170
+
171
+
172
+ def nvidia_rpm_limit(settings: Mapping[str, Any]) -> int:
173
+ try:
174
+ return max(1, min(1000, int(settings.get("llm_rpm_limit", DEFAULT_RPM_LIMIT))))
175
+ except (TypeError, ValueError):
176
+ return DEFAULT_RPM_LIMIT
177
+
178
+
179
+ def nvidia_rpm_window(settings: Mapping[str, Any]) -> float:
180
+ try:
181
+ return max(1.0, min(3600.0, float(settings.get("llm_rpm_window_seconds", DEFAULT_RPM_WINDOW_SECONDS))))
182
+ except (TypeError, ValueError):
183
+ return float(DEFAULT_RPM_WINDOW_SECONDS)
184
+
185
+
186
+ def should_apply_nvidia_rpm(settings: Mapping[str, Any], card: Mapping[str, Any]) -> bool:
187
+ return nvidia_rpm_enabled(settings) and is_nvidia_nim_card(card)
188
+
189
+
190
+ def acquire_nvidia_rpm_slot(
191
+ settings: Mapping[str, Any],
192
+ card: Mapping[str, Any],
193
+ *,
194
+ sleep: bool = True,
195
+ now: Callable[[], float] = _now,
196
+ ) -> float:
197
+ """Reserve one request slot; return seconds waited (or required when sleep=False)."""
198
+ if not should_apply_nvidia_rpm(settings, card):
199
+ return 0.0
200
+ ensure_storage()
201
+ path = STORAGE / "state" / RATE_LIMIT_FILENAME
202
+ lock = path.with_suffix(path.suffix + ".lock")
203
+ limit = nvidia_rpm_limit(settings)
204
+ window = nvidia_rpm_window(settings)
205
+ started = now()
206
+ while True:
207
+ with _exclusive_lock(lock):
208
+ state = _load_state(path, {})
209
+ if not isinstance(state, dict):
210
+ state = {}
211
+ bucket_id = _card_fingerprint(card)
212
+ timestamps = state.get(bucket_id, [])
213
+ if not isinstance(timestamps, list):
214
+ timestamps = []
215
+ current = now()
216
+ timestamps = [float(item) for item in timestamps if current - float(item) < window]
217
+ if len(timestamps) < limit:
218
+ timestamps.append(current)
219
+ state[bucket_id] = timestamps
220
+ _save_state(path, state)
221
+ return max(0.0, current - started)
222
+ wait_for = max(0.0, timestamps[0] + window - current)
223
+ if not sleep:
224
+ return wait_for
225
+ if wait_for > 0:
226
+ time.sleep(wait_for)
227
+
228
+
229
+ def _status_category(status_code: int) -> tuple[str, bool]:
230
+ if status_code == 429:
231
+ return "quota", True
232
+ if status_code in {408, 425} or status_code >= 500:
233
+ return "transient", True
234
+ if status_code in {401, 403}:
235
+ return "credential", False
236
+ if status_code == 404:
237
+ return "endpoint_or_model", False
238
+ if 400 <= status_code < 500:
239
+ return "payload", False
240
+ return "unknown", False
241
+
242
+
243
+ def _retry_after(response: Any) -> float | None:
244
+ value = str(getattr(response, "headers", {}).get("Retry-After") or "").strip()
245
+ try:
246
+ return max(0.0, min(3600.0, float(value))) if value else None
247
+ except (TypeError, ValueError):
248
+ return None
249
+
250
+
251
+ def _detail(response: Any) -> str:
252
+ text = str(getattr(response, "text", "") or "").strip().replace("\n", " ")
253
+ return text[:240] or "sem detalhe devolvido pelo provider"
254
+
255
+
256
+ def classify_request_exception(exc: BaseException) -> ProviderCallError:
257
+ return ProviderCallError(
258
+ f"Falha de transporte no provider: {str(exc)[:220]}",
259
+ category="transient",
260
+ retryable=True,
261
+ )
262
+
263
+
264
+ def classify_response(response: Any) -> None:
265
+ status_code = int(getattr(response, "status_code", 0) or 0)
266
+ if 200 <= status_code < 300:
267
+ return
268
+ category, retryable = _status_category(status_code)
269
+ raise ProviderCallError(
270
+ f"Provider devolveu HTTP {status_code}: {_detail(response)}",
271
+ status_code=status_code,
272
+ category=category,
273
+ retryable=retryable,
274
+ retry_after=_retry_after(response),
275
+ )
276
+
277
+
278
+ def enabled_cards(settings: Mapping[str, Any], pool: str) -> list[dict[str, Any]]:
279
+ """Return cards in priority order; capability filtering is applied by media adapters."""
280
+ if pool == POOL_LLM:
281
+ migrated, _ = ensure_llm_provider_cards(settings)
282
+ raw_cards = migrated.get("llm_provider_cards", [])
283
+ cards = [normalize_llm_card(item, index) for index, item in enumerate(raw_cards)] if isinstance(raw_cards, list) else []
284
+ active_id = str(migrated.get(LLM_ACTIVE_CARD_KEY) or "")
285
+ else:
286
+ raw_cards = settings.get("media_provider_cards", [])
287
+ cards = [dict(item) for item in raw_cards if isinstance(item, Mapping)] if isinstance(raw_cards, list) else []
288
+ active_key = "media_image_active_card_id" if pool == POOL_IMAGE else "media_video_active_card_id"
289
+ active_id = str(settings.get(active_key) or "")
290
+ enabled = [card for card in cards if bool(card.get("enabled", True))]
291
+ if active_id:
292
+ enabled.sort(key=lambda item: 0 if str(item.get("id")) == active_id else 1)
293
+ return enabled
294
+
295
+
296
+ def _attempt_record(pool: str, card: Mapping[str, Any], *, started: float, finished: float, status_code: int | None, category: str, error: str = "", waited: float = 0.0) -> dict[str, Any]:
297
+ return {
298
+ "created_at": _iso(finished),
299
+ "pool": pool,
300
+ **_redacted_card_metadata(card),
301
+ "status_code": status_code,
302
+ "category": category,
303
+ "error": str(error or "")[:240],
304
+ "duration_ms": int(max(0.0, finished - started) * 1000),
305
+ "rate_limit_wait_ms": int(max(0.0, waited) * 1000),
306
+ }
307
+
308
+
309
+ def record_provider_attempt(record: Mapping[str, Any]) -> None:
310
+ ensure_storage()
311
+ path = STORAGE / "state" / ATTEMPTS_FILENAME
312
+ lock = path.with_suffix(path.suffix + ".lock")
313
+ with _exclusive_lock(lock):
314
+ entries = _load_state(path, [])
315
+ if not isinstance(entries, list):
316
+ entries = []
317
+ entries.append(dict(record))
318
+ _save_state(path, entries[-2000:])
319
+
320
+
321
+ def provider_cooldown_remaining(card: Mapping[str, Any], *, now: Callable[[], float] = _now) -> float:
322
+ ensure_storage()
323
+ path = STORAGE / "state" / COOLDOWNS_FILENAME
324
+ state = _load_state(path, {})
325
+ if not isinstance(state, dict):
326
+ return 0.0
327
+ try:
328
+ return max(0.0, float(state.get(_card_fingerprint(card), 0.0)) - now())
329
+ except (TypeError, ValueError):
330
+ return 0.0
331
+
332
+
333
+ def set_provider_cooldown(card: Mapping[str, Any], seconds: float) -> None:
334
+ ensure_storage()
335
+ path = STORAGE / "state" / COOLDOWNS_FILENAME
336
+ lock = path.with_suffix(path.suffix + ".lock")
337
+ with _exclusive_lock(lock):
338
+ state = _load_state(path, {})
339
+ if not isinstance(state, dict):
340
+ state = {}
341
+ state[_card_fingerprint(card)] = _now() + max(0.0, min(3600.0, float(seconds)))
342
+ _save_state(path, state)
343
+
344
+
345
+ def clear_provider_cooldown(card: Mapping[str, Any]) -> None:
346
+ ensure_storage()
347
+ path = STORAGE / "state" / COOLDOWNS_FILENAME
348
+ lock = path.with_suffix(path.suffix + ".lock")
349
+ with _exclusive_lock(lock):
350
+ state = _load_state(path, {})
351
+ if not isinstance(state, dict):
352
+ return
353
+ state.pop(_card_fingerprint(card), None)
354
+ _save_state(path, state)
355
+
356
+
357
+ def route_json_request(
358
+ settings: Mapping[str, Any],
359
+ *,
360
+ pool: str,
361
+ cards: Iterable[Mapping[str, Any]] | None,
362
+ request: Callable[[dict[str, Any]], Any],
363
+ max_attempts: int | None = None,
364
+ cooldown_seconds: float | None = None,
365
+ ) -> RoutedResponse:
366
+ """Execute a JSON request over eligible cards with bounded, classified failover."""
367
+ if pool not in POOLS:
368
+ raise ProviderRoutingError(f"Pool de providers inválido: {pool}")
369
+ candidates = [dict(item) for item in (cards if cards is not None else enabled_cards(settings, pool))]
370
+ if not candidates:
371
+ raise ProviderRoutingError(f"Não existem providers activos no pool {pool}.")
372
+ try:
373
+ maximum = max(1, min(len(candidates), int(max_attempts or settings.get("provider_max_attempts", DEFAULT_MAX_ATTEMPTS))))
374
+ except (TypeError, ValueError):
375
+ maximum = min(len(candidates), DEFAULT_MAX_ATTEMPTS)
376
+ cooldown = float(cooldown_seconds if cooldown_seconds is not None else settings.get("provider_cooldown_seconds", DEFAULT_COOLDOWN_SECONDS))
377
+ attempts: list[dict[str, Any]] = []
378
+ for card in candidates[:maximum]:
379
+ started = _now()
380
+ waited = 0.0
381
+ remaining = provider_cooldown_remaining(card)
382
+ if remaining > 0:
383
+ record = _attempt_record(pool, card, started=started, finished=_now(), status_code=None, category="cooldown", error=f"provider em cooldown por mais de {int(remaining)}s")
384
+ attempts.append(record)
385
+ record_provider_attempt(record)
386
+ continue
387
+ try:
388
+ if pool == POOL_LLM:
389
+ waited = acquire_nvidia_rpm_slot(settings, card)
390
+ response = request(card)
391
+ if isinstance(response, Mapping):
392
+ status_code = response.get("status_code")
393
+ else:
394
+ status_code = getattr(response, "status_code", None)
395
+ classify_response(response)
396
+ payload = response if isinstance(response, dict) else response.json()
397
+ if not isinstance(payload, dict):
398
+ raise ProviderCallError("Provider devolveu um payload JSON inválido.", category="payload", retryable=False)
399
+ record = _attempt_record(pool, card, started=started, finished=_now(), status_code=int(status_code or 200), category="success", waited=waited)
400
+ attempts.append(record)
401
+ record_provider_attempt(record)
402
+ clear_provider_cooldown(card)
403
+ return RoutedResponse(card=card, payload=payload, attempts=tuple(attempts))
404
+ except ProviderCallError as exc:
405
+ record = _attempt_record(pool, card, started=started, finished=_now(), status_code=exc.status_code, category=exc.category, error=str(exc), waited=waited)
406
+ attempts.append(record)
407
+ record_provider_attempt(record)
408
+ if exc.retryable or exc.category in {"credential", "endpoint_or_model"}:
409
+ set_provider_cooldown(card, exc.retry_after or cooldown or DEFAULT_COOLDOWN_SECONDS)
410
+ if not exc.retryable:
411
+ raise ProviderRoutingError(str(exc), attempts=attempts) from exc
412
+ if exc.retry_after:
413
+ time.sleep(min(3600.0, exc.retry_after))
414
+ elif cooldown > 0 and len(attempts) < maximum:
415
+ time.sleep(min(cooldown, 5.0))
416
+ except requests.RequestException as exc:
417
+ classified = classify_request_exception(exc)
418
+ record = _attempt_record(pool, card, started=started, finished=_now(), status_code=None, category=classified.category, error=str(classified), waited=waited)
419
+ attempts.append(record)
420
+ record_provider_attempt(record)
421
+ set_provider_cooldown(card, cooldown or DEFAULT_COOLDOWN_SECONDS)
422
+ if len(attempts) >= maximum:
423
+ raise ProviderRoutingError(str(classified), attempts=attempts) from exc
424
+ if cooldown > 0:
425
+ time.sleep(min(cooldown, 5.0))
426
+ except (ValueError, OSError) as exc:
427
+ classified = ProviderCallError(f"Resposta/IO inválido do provider: {str(exc)[:220]}", category="payload", retryable=False)
428
+ record = _attempt_record(pool, card, started=started, finished=_now(), status_code=None, category=classified.category, error=str(classified), waited=waited)
429
+ attempts.append(record)
430
+ record_provider_attempt(record)
431
+ raise ProviderRoutingError(str(classified), attempts=attempts) from exc
432
+ raise ProviderRoutingError(f"Todos os providers do pool {pool} falharam.", attempts=attempts)
433
+
434
+
435
+ def route_llm_json(settings: Mapping[str, Any], system_prompt: str, user_prompt: str) -> RoutedResponse:
436
+ """Send an OpenAI-compatible JSON chat call through the LLM pool."""
437
+ def request(card: dict[str, Any]) -> Any:
438
+ definition = provider_definition(card.get("provider"))
439
+ base_url = str(card.get("base_url") or definition.default_base_url).strip().rstrip("/")
440
+ endpoint = f"{base_url}/chat/completions"
441
+ headers = {"Content-Type": "application/json"}
442
+ api_key = str(card.get("api_key") or "").strip()
443
+ if api_key:
444
+ headers["Authorization"] = f"Bearer {api_key}"
445
+ body = {
446
+ "model": str(card.get("model") or "").strip(),
447
+ "messages": [
448
+ {"role": "system", "content": system_prompt},
449
+ {"role": "user", "content": user_prompt},
450
+ ],
451
+ "response_format": {"type": "json_object"},
452
+ }
453
+ if not body["model"]:
454
+ raise ProviderCallError("O cartão LLM não tem modelo configurado.", category="payload", retryable=False)
455
+ return requests.post(endpoint, headers=headers, json=body, timeout=120)
456
+
457
+ return route_json_request(settings, pool=POOL_LLM, cards=None, request=request)
@@ -89,6 +89,10 @@ DEFAULTS: dict[str, Any] = {
89
89
  "moneyprinter_path": "",
90
90
  "script_interval_minutes": 10,
91
91
  "llm_rpm_limit": 40,
92
+ "llm_rpm_limit_enabled": False,
93
+ "llm_rpm_window_seconds": 60,
94
+ "provider_max_attempts": 3,
95
+ "provider_cooldown_seconds": 2,
92
96
  "video_concurrency": 3,
93
97
  "upload_concurrency": 2,
94
98
  "youtube_api_key": "",
@@ -160,6 +164,23 @@ DEFAULTS: dict[str, Any] = {
160
164
  "gemini_image_model": "gemini-3.1-flash-image",
161
165
  "gemini_image_aspect_ratio": "16:9",
162
166
  "gemini_image_size": "1K",
167
+ "media_provider_cards": [{
168
+ "id": "media-nano-banana-default",
169
+ "provider": "nano_banana",
170
+ "api_key": "",
171
+ "model": "gemini-3.1-flash-image",
172
+ "base_url": "https://generativelanguage.googleapis.com/v1beta",
173
+ "enabled": True,
174
+ "priority": 0,
175
+ "supports_image": True,
176
+ "supports_video": False,
177
+ "supports_text": False,
178
+ }],
179
+ "media_image_active_card_id": "media-nano-banana-default",
180
+ "media_video_active_card_id": "",
181
+ "media_image_provider": "nano_banana",
182
+ "media_video_provider": "",
183
+ "media_video_pool_enabled": False,
163
184
  "deepseek_api_key": "",
164
185
  "deepseek_base_url": "",
165
186
  "deepseek_model_name": "",
@@ -347,15 +368,18 @@ def _migrate_settings(settings: Any) -> tuple[dict[str, Any], bool]:
347
368
  changed = True
348
369
 
349
370
  # Import localmente para evitar que o módulo de catálogo dependa do storage.
350
- # Settings antigos com um provider explícito continuam a ser devolvidos sem
351
- # alteração textual; os consumidores/UI fazem a materialização preguiçosa dos
352
- # cartões, evitando alterar dados durante uma simples leitura de compatibilidade.
353
- if "llm_provider_cards" not in migrated and provider not in LEGACY_DEFAULT_LLM_PROVIDERS:
354
- return settings, changed
371
+ # Materializar ambos os schemas durante a leitura mantém settings antigos
372
+ # compatíveis, sem eliminar as chaves legadas que ainda são consumidas pelo
373
+ # pipeline e pela UI.
355
374
  from hermes_ui.llm_providers import ensure_llm_provider_cards
356
-
357
- migrated, cards_changed = ensure_llm_provider_cards(migrated)
358
- return migrated, changed or cards_changed
375
+ from hermes_ui.media_providers import ensure_media_provider_cards
376
+
377
+ if "llm_provider_cards" in migrated or provider in LEGACY_DEFAULT_LLM_PROVIDERS:
378
+ migrated, cards_changed = ensure_llm_provider_cards(migrated)
379
+ else:
380
+ cards_changed = False
381
+ migrated, media_changed = ensure_media_provider_cards(migrated)
382
+ return migrated, changed or cards_changed or media_changed
359
383
 
360
384
 
361
385
  def ensure_storage() -> None:
@@ -182,7 +182,6 @@ def generate_thumbnail_image(
182
182
  "response_format": {
183
183
  "type": "image",
184
184
  "mime_type": DEFAULT_MIME_TYPE,
185
- "delivery": "inline",
186
185
  "aspect_ratio": aspect_ratio,
187
186
  "image_size": image_size,
188
187
  },
@@ -8,10 +8,49 @@ from pathlib import Path
8
8
  from typing import Any
9
9
 
10
10
  from .creative_generation import generate_thumbnail_prompt
11
+ from .media_generation import generate_image_from_pool
12
+ from .media_providers import media_cards_for_pool
11
13
  from .storage import STORAGE, now, read_json, write_json
12
14
  from .thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
13
15
 
14
16
 
17
+ def _generate_image_with_pool(
18
+ settings: dict[str, Any],
19
+ prompt: str,
20
+ *,
21
+ topic: str,
22
+ variant_index: int,
23
+ lettering_text: str = "",
24
+ lettering_prompt: str = "",
25
+ reference_image: Path | None = None,
26
+ ) -> Path:
27
+ """Use the legacy Nano adapter when it is the only active image card.
28
+
29
+ This keeps existing callers and test seams stable while multiple configured
30
+ cards use the new image router and failover path.
31
+ """
32
+ cards = media_cards_for_pool(settings, "image")
33
+ if len(cards) == 1 and str(cards[0].get("provider") or "") == "nano_banana":
34
+ return generate_thumbnail_image(
35
+ settings,
36
+ prompt,
37
+ topic=topic,
38
+ variant_index=variant_index,
39
+ reference_image=reference_image,
40
+ lettering_text=lettering_text,
41
+ lettering_prompt=lettering_prompt,
42
+ )
43
+ return generate_image_from_pool(
44
+ settings,
45
+ prompt,
46
+ topic=topic,
47
+ variant_index=variant_index,
48
+ reference_image=reference_image,
49
+ lettering_text=lettering_text,
50
+ lettering_prompt=lettering_prompt,
51
+ )
52
+
53
+
15
54
  def _as_path(value: Any) -> Path | None:
16
55
  raw = str(value or "").strip()
17
56
  if not raw:
@@ -200,7 +239,7 @@ def generate_thumbnail_for_task(task_id: str, settings: dict[str, Any]) -> tuple
200
239
  if not record["prompt"]:
201
240
  raise ThumbnailGenerationError("A thumbnail não tem um prompt de imagem para gerar.")
202
241
  _archive_image(str(task_id), record.get("image_path"))
203
- image_path = generate_thumbnail_image(
242
+ image_path = _generate_image_with_pool(
204
243
  settings,
205
244
  record["prompt"],
206
245
  topic=record["title"] or record["topic"],
@@ -255,7 +294,7 @@ def regenerate_thumbnail_prompt_and_image(
255
294
  if not prompt:
256
295
  raise ThumbnailGenerationError("O provider não devolveu um prompt de imagem válido.")
257
296
  _archive_image(str(task_id), record.get("image_path"))
258
- image_path = generate_thumbnail_image(
297
+ image_path = _generate_image_with_pool(
259
298
  settings,
260
299
  prompt,
261
300
  topic=record["title"] or record["topic"],
@@ -296,7 +335,7 @@ def regenerate_thumbnail_lettering(
296
335
  "Não adicionar logótipos, marcas de água ou outros elementos."
297
336
  )
298
337
  _archive_image(str(task_id), previous_image)
299
- image_path = generate_thumbnail_image(
338
+ image_path = _generate_image_with_pool(
300
339
  settings,
301
340
  combined_prompt,
302
341
  topic=record["title"] or record["topic"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.41",
3
+ "version": "0.3.43",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",