@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 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
- cards.sort(key=lambda card: int(card.get("priority", 0)))
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, str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or ""), str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or ""))
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
- direct = str(payload.get("video_url") or payload.get("url") or raw_output or "").strip()
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 definida no próprio cartão é o contrato do failover.
353
- cards.sort(key=lambda card: int(card.get("priority", 0)))
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.26",
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": {"thunderbolt": "scripts/cli.mjs"},
9
- "files": ["app/main.py", "app/influencers_ui.py", "app/modules/**/*.py", "hermes_ui/*.py", "integrations/*.py", "integrations/data/*.json", "scripts/*.mjs", "scripts/*.py", "seed/prompt_masters/**/*.md", "seed/blueprints/**/*.json", "seed/blueprints/**/*.md", "seed/skills/*.md", "seed/skills/*.py", "seed/references/*.md", "seed/references/*.sql", "README.md", "CHANGELOG.md", "MANUAL-INSTALACAO.md", "THIRD-PARTY-NOTICES.md", "requirements.txt", "sitecustomize.py", "package.json", ".streamlit/config.toml"],
10
- "scripts": {"postinstall": "node scripts/npx-cache-compat.mjs", "start": "node scripts/cli.mjs", "check": "node scripts/cli.mjs --check", "pack:check": "npm pack --dry-run"},
11
- "publishConfig": {"access": "public"},
12
- "engines": {"node": ">=18"}
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.