@danhachuel/thunderbolt 0.5.26 → 0.5.28
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/app/main.py +35 -2
- package/hermes_ui/api_key_tests.py +7 -0
- package/hermes_ui/media_generation.py +22 -1
- package/hermes_ui/media_providers.py +29 -2
- package/package.json +40 -6
- package/CHANGELOG.md +0 -25
package/app/main.py
CHANGED
|
@@ -7087,6 +7087,24 @@ def _persist_media_cards(settings: dict[str, Any], cards: list[dict[str, Any]],
|
|
|
7087
7087
|
def _fetch_media_models(card: dict[str, Any]) -> list[str]:
|
|
7088
7088
|
"""Consultar modelos do provider, incluindo o catálogo nativo Gemini."""
|
|
7089
7089
|
provider = str(card.get("provider") or "").strip().lower()
|
|
7090
|
+
if provider == "openrouter":
|
|
7091
|
+
base_url = str(card.get("base_url") or "https://openrouter.ai/api/v1").rstrip("/")
|
|
7092
|
+
headers = {"Accept": "application/json", "Authorization": f"Bearer {str(card.get('api_key') or '').strip()}"}
|
|
7093
|
+
discovered: set[str] = set()
|
|
7094
|
+
failures: list[str] = []
|
|
7095
|
+
for suffix in ("/images/models", "/videos/models"):
|
|
7096
|
+
response = requests.get(f"{base_url}{suffix}", headers=headers, timeout=12)
|
|
7097
|
+
if response.status_code >= 400:
|
|
7098
|
+
failures.append(f"HTTP {response.status_code}")
|
|
7099
|
+
continue
|
|
7100
|
+
payload = response.json()
|
|
7101
|
+
entries = payload.get("data") if isinstance(payload, dict) else []
|
|
7102
|
+
if isinstance(entries, list):
|
|
7103
|
+
discovered.update(str(item.get("id") or "").strip() for item in entries if isinstance(item, dict) and str(item.get("id") or "").strip())
|
|
7104
|
+
if not discovered:
|
|
7105
|
+
detail = f" ({', '.join(failures)})" if failures else ""
|
|
7106
|
+
raise ValueError(f"O OpenRouter não devolveu modelos de imagem/vídeo{detail}.")
|
|
7107
|
+
return sorted(discovered, key=str.casefold)
|
|
7090
7108
|
if provider == "nano_banana":
|
|
7091
7109
|
base_url = str(card.get("base_url") or "https://generativelanguage.googleapis.com/v1beta").rstrip("/")
|
|
7092
7110
|
response = requests.get(
|
|
@@ -7285,7 +7303,22 @@ def render_media_provider_cards(settings: dict[str, Any], *, embedded: bool = Fa
|
|
|
7285
7303
|
with st.expander("Imagem e Video IA", expanded=False):
|
|
7286
7304
|
full_ia_labels = ", ".join(media_provider_definition(code).label for code in FULL_IA_VIDEO_PROVIDER_CODES)
|
|
7287
7305
|
st.caption(f"Configure providers de imagem e vídeo em cartões independentes. O router usa apenas o pool correspondente e faz failover entre providers activos. Pool Full IA: {full_ia_labels}.")
|
|
7288
|
-
|
|
7306
|
+
image_cards = [card for card in cards if card.get("supports_image")]
|
|
7307
|
+
video_cards = [card for card in cards if card.get("supports_video")]
|
|
7308
|
+
selector_cols = st.columns(3)
|
|
7309
|
+
image_options = [""] + [str(card.get("id")) for card in image_cards]
|
|
7310
|
+
video_options = [""] + [str(card.get("id")) for card in video_cards]
|
|
7311
|
+
with selector_cols[0]:
|
|
7312
|
+
image_active_id = st.selectbox("Provider principal de imagem", image_options, index=image_options.index(str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or "")) if str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or "") in image_options else 0, format_func=lambda value: "Automático / primeiro activo" if not value else next((media_provider_definition(card.get("provider")).label for card in image_cards if str(card.get("id")) == value), value), key="media_image_active_selector")
|
|
7313
|
+
with selector_cols[1]:
|
|
7314
|
+
video_active_id = st.selectbox("Provider principal de vídeo", video_options, index=video_options.index(str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or "")) if str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or "") in video_options else 0, format_func=lambda value: "Não usar pool externo" if not value else next((media_provider_definition(card.get("provider")).label for card in video_cards if str(card.get("id")) == value), value), key="media_video_active_selector")
|
|
7315
|
+
with selector_cols[2]:
|
|
7316
|
+
video_pool_enabled = st.checkbox("Usar pool de vídeo externo", value=bool(settings.get("media_video_pool_enabled", False)), key="media_video_pool_enabled_ui")
|
|
7317
|
+
if st.button("Salvar selecção dos pools", use_container_width=True, key="save_media_pool_selection") if not embedded else st.form_submit_button("Salvar selecção dos pools", use_container_width=True, key="save_media_pool_selection"):
|
|
7318
|
+
settings["media_video_pool_enabled"] = bool(video_pool_enabled)
|
|
7319
|
+
_persist_media_cards(settings, cards, image_active_id, video_active_id)
|
|
7320
|
+
st.success("Selecção dos pools guardada.")
|
|
7321
|
+
st.rerun()
|
|
7289
7322
|
for index in range(len(cards)):
|
|
7290
7323
|
_render_media_provider_card(settings, cards, index, embedded=embedded)
|
|
7291
7324
|
st.divider()
|
|
@@ -7295,7 +7328,7 @@ def render_media_provider_cards(settings: dict[str, Any], *, embedded: bool = Fa
|
|
|
7295
7328
|
add_clicked = st.form_submit_button("Adicionar provider de imagem/vídeo", use_container_width=True, key="add_media_provider_card") if embedded else st.button("Adicionar provider de imagem/vídeo", use_container_width=True, key="add_media_provider_card")
|
|
7296
7329
|
if add_clicked:
|
|
7297
7330
|
cards.append(new_media_card(provider_to_add, card_id=f"media-{provider_to_add}-{uuid.uuid4().hex[:8]}"))
|
|
7298
|
-
_persist_media_cards(settings, cards,
|
|
7331
|
+
_persist_media_cards(settings, cards, image_active_id, video_active_id)
|
|
7299
7332
|
st.success("Novo provider de imagem/vídeo adicionado.")
|
|
7300
7333
|
st.rerun()
|
|
7301
7334
|
|
|
@@ -15,6 +15,7 @@ from urllib.parse import quote, urlsplit, urlunsplit
|
|
|
15
15
|
import requests
|
|
16
16
|
|
|
17
17
|
from app.modules.niche_finder.apify import APIFY_API_BASE
|
|
18
|
+
from integrations.openai_model_discovery import OpenAICompatibleAPIError, validate_openrouter_api_key
|
|
18
19
|
|
|
19
20
|
DEFAULT_TIMEOUT = 20
|
|
20
21
|
|
|
@@ -176,6 +177,12 @@ def test_media_provider_card(card: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
176
177
|
return _get(f"{base_url}/v3/users/me", headers={"X-Api-Key": api_key})
|
|
177
178
|
if not model and provider not in {"inferenceport", "cloudflare_workers_ai", "heygen"}:
|
|
178
179
|
return _result("missing", "Complete o modelo antes de testar.")
|
|
180
|
+
if provider == "openrouter":
|
|
181
|
+
try:
|
|
182
|
+
validate_openrouter_api_key(api_key, base_url, model)
|
|
183
|
+
except OpenAICompatibleAPIError as exc:
|
|
184
|
+
return _result("error", str(exc)[:240])
|
|
185
|
+
return _result("success", "OpenRouter API Key e modelo OK")
|
|
179
186
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
180
187
|
if provider == "cloudflare_workers_ai":
|
|
181
188
|
account_id = str(source.get("account_id") or "").strip()
|
|
@@ -252,6 +252,8 @@ def _image_endpoint(card: Mapping[str, Any]) -> str:
|
|
|
252
252
|
explicit = str(card.get("image_endpoint") or "").strip()
|
|
253
253
|
if explicit:
|
|
254
254
|
return explicit
|
|
255
|
+
if style == "openrouter":
|
|
256
|
+
return f"{base}/images"
|
|
255
257
|
if style in {"openai_compatible", "huggingface", "agnes", "kie"}:
|
|
256
258
|
return f"{base}/images/generations"
|
|
257
259
|
if style == "replicate":
|
|
@@ -309,6 +311,13 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
|
|
|
309
311
|
"extra_body": {"response_format": "b64_json"},
|
|
310
312
|
}
|
|
311
313
|
return requests.post(endpoint, headers=_headers(card), json=body, timeout=AGNES_IMAGE_TIMEOUT_SECONDS)
|
|
314
|
+
if style == "openrouter":
|
|
315
|
+
return requests.post(
|
|
316
|
+
endpoint,
|
|
317
|
+
headers=_headers(card),
|
|
318
|
+
json={"model": _model(card), "prompt": constrained_prompt, "n": 1, "aspect_ratio": "16:9"},
|
|
319
|
+
timeout=180,
|
|
320
|
+
)
|
|
312
321
|
body = {"model": _model(card), "prompt": constrained_prompt, "n": 1, "response_format": "b64_json"}
|
|
313
322
|
if provider == "pollinations":
|
|
314
323
|
body["size"] = "1792x1024"
|
|
@@ -480,6 +489,8 @@ def _video_endpoint(card: Mapping[str, Any]) -> str:
|
|
|
480
489
|
if not _model(card):
|
|
481
490
|
raise MediaGenerationError("FAL AI requer o identificador da rota/modelo para gerar vídeo.")
|
|
482
491
|
return f"{base}/{_model(card).lstrip('/')}"
|
|
492
|
+
if style == "openrouter":
|
|
493
|
+
return f"{base}/videos"
|
|
483
494
|
if style in {"openai_compatible", "agnes", "kie"}:
|
|
484
495
|
return f"{base}/videos/generations"
|
|
485
496
|
if style == "replicate":
|
|
@@ -503,6 +514,10 @@ def _video_request(card: dict[str, Any], prompt: str, image_url: str = "") -> An
|
|
|
503
514
|
}
|
|
504
515
|
if image_url:
|
|
505
516
|
body["image_url"] = image_url
|
|
517
|
+
if style == "openrouter":
|
|
518
|
+
body["aspect_ratio"] = str(card.get("aspect_ratio") or INTERNAL_VIDEO_ASPECT_RATIO)
|
|
519
|
+
body["resolution"] = str(card.get("video_size") or INTERNAL_VIDEO_SIZE)
|
|
520
|
+
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
506
521
|
if style == "heygen":
|
|
507
522
|
avatar_id = str(card.get("avatar_id") or "").strip()
|
|
508
523
|
if not avatar_id:
|
|
@@ -534,7 +549,11 @@ def _video_result(payload: Mapping[str, Any]) -> tuple[str, str]:
|
|
|
534
549
|
raw_output = payload.get("output")
|
|
535
550
|
if isinstance(raw_output, list) and raw_output:
|
|
536
551
|
raw_output = raw_output[0]
|
|
537
|
-
|
|
552
|
+
unsigned_urls = payload.get("unsigned_urls")
|
|
553
|
+
if isinstance(unsigned_urls, list) and unsigned_urls:
|
|
554
|
+
direct = str(unsigned_urls[0] or "").strip()
|
|
555
|
+
else:
|
|
556
|
+
direct = str(payload.get("content_url") or payload.get("video_url") or payload.get("url") or raw_output or "").strip()
|
|
538
557
|
if direct.startswith(("http://", "https://")):
|
|
539
558
|
return direct, ""
|
|
540
559
|
result = payload.get("result")
|
|
@@ -576,6 +595,8 @@ def _poll_video(card: Mapping[str, Any], request_id: str, *, attempts: int = 24,
|
|
|
576
595
|
endpoint = f"{base}/predictions/{request_id}"
|
|
577
596
|
elif style == "heygen":
|
|
578
597
|
endpoint = f"{base}/v3/videos/{request_id}"
|
|
598
|
+
elif style == "openrouter":
|
|
599
|
+
endpoint = f"{base}/videos/{request_id}"
|
|
579
600
|
else:
|
|
580
601
|
endpoint = f"{base}/videos/{request_id}"
|
|
581
602
|
headers = _headers(card, fal=style == "fal_queue")
|
|
@@ -22,6 +22,7 @@ FULL_IA_VIDEO_PROVIDER_CODES = (
|
|
|
22
22
|
"huggingface",
|
|
23
23
|
"inferenceport",
|
|
24
24
|
"heygen",
|
|
25
|
+
"openrouter",
|
|
25
26
|
)
|
|
26
27
|
|
|
27
28
|
# Mantidos internamente para preservar compatibilidade e controlar o prompt dos providers.
|
|
@@ -165,6 +166,16 @@ MEDIA_PROVIDER_CATALOG: tuple[MediaProviderDefinition, ...] = (
|
|
|
165
166
|
extra_fields=("avatar_id", "voice_id"),
|
|
166
167
|
description="Vídeos com avatar, script ou áudio pela API V3 da HeyGen.",
|
|
167
168
|
),
|
|
169
|
+
MediaProviderDefinition(
|
|
170
|
+
"openrouter",
|
|
171
|
+
"OpenRouter",
|
|
172
|
+
default_base_url="https://openrouter.ai/api/v1",
|
|
173
|
+
supports_image=True,
|
|
174
|
+
supports_video=True,
|
|
175
|
+
supports_text=True,
|
|
176
|
+
api_style="openrouter",
|
|
177
|
+
description="Gateway unificado para modelos de geração de imagem e vídeo via APIs dedicadas do OpenRouter.",
|
|
178
|
+
),
|
|
168
179
|
)
|
|
169
180
|
|
|
170
181
|
_MEDIA_BY_CODE = {item.code: item for item in MEDIA_PROVIDER_CATALOG}
|
|
@@ -251,6 +262,13 @@ def normalize_media_card(card: Any, index: int = 0) -> dict[str, Any]:
|
|
|
251
262
|
return result
|
|
252
263
|
|
|
253
264
|
|
|
265
|
+
def _ordered_media_cards(cards: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
|
266
|
+
"""Ordenar os cartões pela prioridade, preservando a ordem em caso de empate."""
|
|
267
|
+
indexed = [(index, normalize_media_card(card, index)) for index, card in enumerate(cards)]
|
|
268
|
+
indexed.sort(key=lambda pair: (int(pair[1].get("priority", pair[0])), pair[0]))
|
|
269
|
+
return [card for _index, card in indexed]
|
|
270
|
+
|
|
271
|
+
|
|
254
272
|
def new_media_card(provider: Any, *, card_id: str | None = None) -> dict[str, Any]:
|
|
255
273
|
code = str(provider or "").strip().lower()
|
|
256
274
|
if code not in _MEDIA_BY_CODE:
|
|
@@ -294,6 +312,10 @@ def ensure_media_provider_cards(settings: Mapping[str, Any]) -> tuple[dict[str,
|
|
|
294
312
|
)
|
|
295
313
|
)
|
|
296
314
|
changed = True
|
|
315
|
+
ordered_cards = _ordered_media_cards(cards)
|
|
316
|
+
if ordered_cards != cards:
|
|
317
|
+
changed = True
|
|
318
|
+
cards = ordered_cards
|
|
297
319
|
result[MEDIA_CARDS_KEY] = cards
|
|
298
320
|
|
|
299
321
|
for pool, key, capability in (
|
|
@@ -321,6 +343,7 @@ def apply_media_provider_cards_to_settings(settings: Mapping[str, Any], cards: l
|
|
|
321
343
|
normalized = [normalize_media_card(item, index) for index, item in enumerate(cards)]
|
|
322
344
|
if not normalized:
|
|
323
345
|
normalized = [new_media_card("nano_banana", card_id="media-nano-banana-default")]
|
|
346
|
+
normalized = _ordered_media_cards(normalized)
|
|
324
347
|
result[MEDIA_CARDS_KEY] = normalized
|
|
325
348
|
for key, wanted, capability in (
|
|
326
349
|
(MEDIA_IMAGE_ACTIVE_CARD_KEY, image_active_id, "supports_image"),
|
|
@@ -346,9 +369,13 @@ def apply_media_provider_cards_to_settings(settings: Mapping[str, Any], cards: l
|
|
|
346
369
|
def media_cards_for_pool(settings: Mapping[str, Any], pool: str, *, thumbnail_only: bool = False) -> list[dict[str, Any]]:
|
|
347
370
|
migrated, _ = ensure_media_provider_cards(settings)
|
|
348
371
|
capability = "supports_image" if pool == "image" else "supports_video" if pool == "video" else "supports_text"
|
|
372
|
+
active_key = MEDIA_IMAGE_ACTIVE_CARD_KEY if pool == "image" else MEDIA_VIDEO_ACTIVE_CARD_KEY if pool == "video" else ""
|
|
373
|
+
active_id = str(migrated.get(active_key) or "")
|
|
349
374
|
cards = [dict(item) for item in migrated.get(MEDIA_CARDS_KEY, []) if item.get("enabled", True) and item.get(capability)]
|
|
350
375
|
if pool == "image" and not thumbnail_only:
|
|
351
376
|
cards = [card for card in cards if not card.get("thumbnail_only")]
|
|
352
|
-
# A prioridade
|
|
353
|
-
|
|
377
|
+
# A prioridade é o contrato principal do failover. O cartão activo legado
|
|
378
|
+
# só serve de desempate entre cartões com a mesma prioridade, para não
|
|
379
|
+
# ignorar uma escolha explícita do utilizador por causa de estado antigo.
|
|
380
|
+
cards.sort(key=lambda card: (int(card.get("priority", 0)), 0 if str(card.get("id")) == active_id else 1))
|
|
354
381
|
return cards
|
package/package.json
CHANGED
|
@@ -1,13 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danhachuel/thunderbolt",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.28",
|
|
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",
|
|
7
7
|
"type": "module",
|
|
8
|
-
"bin": {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
|
|
8
|
+
"bin": {
|
|
9
|
+
"thunderbolt": "scripts/cli.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"app/main.py",
|
|
13
|
+
"app/influencers_ui.py",
|
|
14
|
+
"app/modules/**/*.py",
|
|
15
|
+
"hermes_ui/*.py",
|
|
16
|
+
"integrations/*.py",
|
|
17
|
+
"integrations/data/*.json",
|
|
18
|
+
"scripts/*.mjs",
|
|
19
|
+
"scripts/*.py",
|
|
20
|
+
"seed/prompt_masters/**/*.md",
|
|
21
|
+
"seed/blueprints/**/*.json",
|
|
22
|
+
"seed/blueprints/**/*.md",
|
|
23
|
+
"seed/skills/*.md",
|
|
24
|
+
"seed/skills/*.py",
|
|
25
|
+
"seed/references/*.md",
|
|
26
|
+
"seed/references/*.sql",
|
|
27
|
+
"README.md",
|
|
28
|
+
"MANUAL-INSTALACAO.md",
|
|
29
|
+
"THIRD-PARTY-NOTICES.md",
|
|
30
|
+
"requirements.txt",
|
|
31
|
+
"sitecustomize.py",
|
|
32
|
+
"package.json",
|
|
33
|
+
".streamlit/config.toml"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"postinstall": "node scripts/npx-cache-compat.mjs",
|
|
37
|
+
"start": "node scripts/cli.mjs",
|
|
38
|
+
"check": "node scripts/cli.mjs --check",
|
|
39
|
+
"pack:check": "npm pack --dry-run"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
}
|
|
13
47
|
}
|
package/CHANGELOG.md
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.5.26
|
|
4
|
-
|
|
5
|
-
Corrigida a renderização dos cards de Imagem e Video IA para os apresentar imediatamente pela ordem do campo Prioridade, fazendo o card subir ou descer na fila após ser guardado.
|
|
6
|
-
|
|
7
|
-
## 0.5.17
|
|
8
|
-
|
|
9
|
-
Removidos da secção Imagem e Video IA os selectores de provider principal de imagem, provider principal de vídeo e a checkbox de pool de vídeo externo. A ordem dos pools passa a ser definida pelos campos de Prioridade nos próprios cartões.
|
|
10
|
-
|
|
11
|
-
## 0.5.16
|
|
12
|
-
|
|
13
|
-
Adicionado um handler SIGINT no entry point do Streamlit que encerra o processo de forma controlada antes do handler de shutdown do Streamlit chamar `click.secho`, eliminando o caminho que provocava `LookupError: unknown encoding: utf-16-le` ao pressionar Ctrl+C no Windows.
|
|
14
|
-
|
|
15
|
-
## 0.5.15 (normalizado pelo npm para 0.5.15)
|
|
16
|
-
|
|
17
|
-
Aplicada a correcção definitiva do caminho de saída do Click no Windows, com shim UTF-8 carregado antes do Streamlit e teste de regressão para stdout com encoding `utf-16-le`.
|
|
18
|
-
|
|
19
|
-
## 0.5.09 (normalizado pelo npm para 0.5.9)
|
|
20
|
-
|
|
21
|
-
Adicionado um shim de compatibilidade que intercepta a escrita de saída do Click antes do carregamento do Streamlit e grava directamente em UTF-8, evitando o caminho nativo que volta a procurar `utf-16-le` durante o shutdown do Windows. Incluídos testes com um console simulado em `utf-16-le`.
|
|
22
|
-
|
|
23
|
-
## 0.5.08 (normalizado pelo npm para 0.5.8)
|
|
24
|
-
|
|
25
|
-
Corrigido o erro `LookupError: unknown encoding: utf-16-le` que podia ocorrer durante o shutdown do Streamlit no Windows. O launcher Node agora materializa as variáveis de encoding e `CLICK_NO_WIN_CONSOLE` no processo pai antes de iniciar Python; os workers de automação e pipeline também aplicam as mesmas definições antes dos imports restantes.
|