@danhachuel/thunderbolt 0.3.42 → 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.
- package/MANUAL-INSTALACAO.md +42 -7
- package/README.md +16 -8
- package/app/main.py +148 -19
- package/hermes_ui/api_key_tests.py +33 -1
- package/hermes_ui/creative_generation.py +18 -26
- package/hermes_ui/languages.py +8 -2
- package/hermes_ui/llm_providers.py +4 -0
- package/hermes_ui/media_generation.py +383 -0
- package/hermes_ui/media_providers.py +282 -0
- package/hermes_ui/pipeline_worker.py +42 -2
- package/hermes_ui/provider_routing.py +457 -0
- package/hermes_ui/storage.py +32 -8
- package/hermes_ui/thumbnail_generation.py +0 -1
- package/hermes_ui/thumbnails.py +42 -3
- package/package.json +1 -1
|
@@ -17,6 +17,8 @@ from hermes_ui.script_documents import save_script_document
|
|
|
17
17
|
from hermes_ui.script_generation import generate_script_document
|
|
18
18
|
from hermes_ui.storage import STORAGE, ensure_storage, read_json, write_json
|
|
19
19
|
from hermes_ui.llm_providers import active_llm_card, provider_definition
|
|
20
|
+
from hermes_ui.media_generation import MediaGenerationError, generate_image_from_pool, generate_video_from_pool
|
|
21
|
+
from hermes_ui.media_providers import media_cards_for_pool
|
|
20
22
|
from hermes_ui.thumbnail_generation import generate_thumbnail_image
|
|
21
23
|
|
|
22
24
|
PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
|
|
@@ -43,6 +45,36 @@ def _settings() -> dict[str, Any]:
|
|
|
43
45
|
return value if isinstance(value, dict) else {}
|
|
44
46
|
|
|
45
47
|
|
|
48
|
+
def _generate_pipeline_thumbnail(
|
|
49
|
+
settings: dict[str, Any],
|
|
50
|
+
prompt: str,
|
|
51
|
+
*,
|
|
52
|
+
topic: str,
|
|
53
|
+
variant_index: int = 0,
|
|
54
|
+
lettering_text: str = "",
|
|
55
|
+
lettering_prompt: str = "",
|
|
56
|
+
) -> Path:
|
|
57
|
+
"""Use the image pool while keeping the legacy single-Nano call seam."""
|
|
58
|
+
cards = media_cards_for_pool(settings, "image")
|
|
59
|
+
if len(cards) == 1 and str(cards[0].get("provider") or "") == "nano_banana":
|
|
60
|
+
return generate_thumbnail_image(
|
|
61
|
+
settings,
|
|
62
|
+
prompt,
|
|
63
|
+
topic=topic,
|
|
64
|
+
variant_index=variant_index,
|
|
65
|
+
lettering_text=lettering_text,
|
|
66
|
+
lettering_prompt=lettering_prompt,
|
|
67
|
+
)
|
|
68
|
+
return generate_image_from_pool(
|
|
69
|
+
settings,
|
|
70
|
+
prompt,
|
|
71
|
+
topic=topic,
|
|
72
|
+
variant_index=variant_index,
|
|
73
|
+
lettering_text=lettering_text,
|
|
74
|
+
lettering_prompt=lettering_prompt,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
46
78
|
def _lock_path() -> Path:
|
|
47
79
|
ensure_storage()
|
|
48
80
|
return STORAGE / "state" / PIPELINE_LOCK_FILENAME
|
|
@@ -521,7 +553,7 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
521
553
|
artifacts = {**artifacts, "thumbnail_prompt_json": prompt_artifact}
|
|
522
554
|
_update(task_id, stage="thumbnail_prompt", state="doing", progress=52, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
|
|
523
555
|
_update(task_id, stage="thumbnail", state="doing", progress=56, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
|
|
524
|
-
thumbnail_path =
|
|
556
|
+
thumbnail_path = _generate_pipeline_thumbnail(
|
|
525
557
|
settings,
|
|
526
558
|
str(variant.get("image_prompt") or ""),
|
|
527
559
|
topic=topic,
|
|
@@ -533,7 +565,15 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
533
565
|
_update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=62)
|
|
534
566
|
|
|
535
567
|
_update(task_id, stage="video", state="doing", progress=68)
|
|
536
|
-
|
|
568
|
+
video_cards = media_cards_for_pool(settings, "video")
|
|
569
|
+
if bool(settings.get("media_video_pool_enabled")) and video_cards:
|
|
570
|
+
try:
|
|
571
|
+
video_prompt = f"Título: {title}\n\nRoteiro:\n{str(script.get('content') or '')[:12000]}"
|
|
572
|
+
video_path = generate_video_from_pool(settings, video_prompt)
|
|
573
|
+
except MediaGenerationError as exc:
|
|
574
|
+
raise PipelineError(f"Pool de vídeo externo: {exc}") from exc
|
|
575
|
+
else:
|
|
576
|
+
video_path = _run_video_helper({**task, "topic": topic})
|
|
537
577
|
artifacts["video"] = str(video_path)
|
|
538
578
|
_update(task_id, artifacts=artifacts, progress=80)
|
|
539
579
|
|
|
@@ -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)
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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
|
-
#
|
|
351
|
-
#
|
|
352
|
-
#
|
|
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
|
-
|
|
358
|
-
|
|
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:
|
package/hermes_ui/thumbnails.py
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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