@danhachuel/thunderbolt 0.5.27 → 0.5.29

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
@@ -78,7 +78,7 @@ from hermes_ui.mcp import detect_local_service, install_skill_locally, load_inte
78
78
  from hermes_ui.mcp_server import server_status, start_server, stop_server
79
79
  from hermes_ui.material_sources import apply_material_source_cards_to_settings, ensure_material_source_cards, material_source_catalog, material_source_definition, new_material_card, normalize_material_card, selected_material_source
80
80
  from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_PROVIDER_CATALOG, apply_llm_cards_to_settings, ensure_llm_provider_cards, new_llm_card, normalize_llm_card, provider_definition, test_llm_provider_card, stamp_test_result
81
- from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, MEDIA_CARDS_KEY, MEDIA_IMAGE_ACTIVE_CARD_KEY, MEDIA_VIDEO_ACTIVE_CARD_KEY, apply_media_provider_cards_to_settings, ensure_media_provider_cards, media_cards_for_pool, media_provider_catalog, media_provider_definition, new_media_card, normalize_media_card
81
+ from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, KIE_MEDIA_MODEL_CATALOG, MEDIA_CARDS_KEY, MEDIA_IMAGE_ACTIVE_CARD_KEY, MEDIA_VIDEO_ACTIVE_CARD_KEY, apply_media_provider_cards_to_settings, ensure_media_provider_cards, media_cards_for_pool, media_provider_catalog, media_provider_definition, new_media_card, normalize_media_card
82
82
  from hermes_ui.music import create_music_task, list_music_files, list_music_tasks, materialize_suno_audio, request_suno_generation, run_music_task, store_music_file, store_voiceover_file, transition_music_task
83
83
  from hermes_ui.music_generation import MUSIC_GENRES, MUSIC_VOCAL_OPTIONS, generate_music_fields
84
84
  from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
@@ -7087,6 +7087,26 @@ 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 == "kie_ai":
7091
+ return list(KIE_MEDIA_MODEL_CATALOG)
7092
+ if provider == "openrouter":
7093
+ base_url = str(card.get("base_url") or "https://openrouter.ai/api/v1").rstrip("/")
7094
+ headers = {"Accept": "application/json", "Authorization": f"Bearer {str(card.get('api_key') or '').strip()}"}
7095
+ discovered: set[str] = set()
7096
+ failures: list[str] = []
7097
+ for suffix in ("/images/models", "/videos/models"):
7098
+ response = requests.get(f"{base_url}{suffix}", headers=headers, timeout=12)
7099
+ if response.status_code >= 400:
7100
+ failures.append(f"HTTP {response.status_code}")
7101
+ continue
7102
+ payload = response.json()
7103
+ entries = payload.get("data") if isinstance(payload, dict) else []
7104
+ if isinstance(entries, list):
7105
+ discovered.update(str(item.get("id") or "").strip() for item in entries if isinstance(item, dict) and str(item.get("id") or "").strip())
7106
+ if not discovered:
7107
+ detail = f" ({', '.join(failures)})" if failures else ""
7108
+ raise ValueError(f"O OpenRouter não devolveu modelos de imagem/vídeo{detail}.")
7109
+ return sorted(discovered, key=str.casefold)
7090
7110
  if provider == "nano_banana":
7091
7111
  base_url = str(card.get("base_url") or "https://generativelanguage.googleapis.com/v1beta").rstrip("/")
7092
7112
  response = requests.get(
@@ -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()
@@ -191,6 +191,11 @@ def _image_value(payload: Any) -> tuple[bytes | None, str]:
191
191
  def _image_request_id(payload: Any) -> str:
192
192
  if not isinstance(payload, Mapping):
193
193
  return ""
194
+ nested = payload.get("data")
195
+ if isinstance(nested, Mapping):
196
+ task_id = str(nested.get("taskId") or "").strip()
197
+ if task_id:
198
+ return task_id
194
199
  for key in ("id", "prediction_id", "request_id", "task_id", "job_id"):
195
200
  value = str(payload.get(key) or "").strip()
196
201
  if value:
@@ -252,7 +257,11 @@ def _image_endpoint(card: Mapping[str, Any]) -> str:
252
257
  explicit = str(card.get("image_endpoint") or "").strip()
253
258
  if explicit:
254
259
  return explicit
255
- if style in {"openai_compatible", "huggingface", "agnes", "kie"}:
260
+ if style == "openrouter":
261
+ return f"{base}/images"
262
+ if style == "kie":
263
+ return f"{base}/jobs/createTask"
264
+ if style in {"openai_compatible", "huggingface", "agnes"}:
256
265
  return f"{base}/images/generations"
257
266
  if style == "replicate":
258
267
  return f"{base}/predictions"
@@ -299,6 +308,12 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
299
308
  if style == "replicate":
300
309
  body = {"version": _model(card), "input": {"prompt": constrained_prompt}}
301
310
  return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
311
+ if style == "kie":
312
+ body = {
313
+ "model": _model(card),
314
+ "input": {"prompt": constrained_prompt, "aspect_ratio": "16:9", "resolution": "1K"},
315
+ }
316
+ return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
302
317
  if provider == "agnes":
303
318
  body = {
304
319
  "model": _model(card) or AGNES_IMAGE_MODEL,
@@ -309,6 +324,13 @@ def _image_request(card: dict[str, Any], prompt: str, *, topic: str = "", letter
309
324
  "extra_body": {"response_format": "b64_json"},
310
325
  }
311
326
  return requests.post(endpoint, headers=_headers(card), json=body, timeout=AGNES_IMAGE_TIMEOUT_SECONDS)
327
+ if style == "openrouter":
328
+ return requests.post(
329
+ endpoint,
330
+ headers=_headers(card),
331
+ json={"model": _model(card), "prompt": constrained_prompt, "n": 1, "aspect_ratio": "16:9"},
332
+ timeout=180,
333
+ )
312
334
  body = {"model": _model(card), "prompt": constrained_prompt, "n": 1, "response_format": "b64_json"}
313
335
  if provider == "pollinations":
314
336
  body["size"] = "1792x1024"
@@ -416,6 +438,9 @@ def generate_image_for_card(
416
438
  style = str(routed.card.get("api_style") or media_provider_definition(routed.card.get("provider")).api_style)
417
439
  if request_id and style == "replicate":
418
440
  image_bytes, url = _poll_image(routed.card, request_id)
441
+ elif request_id and style == "kie":
442
+ urls = _poll_kie_task(routed.card, request_id, endpoint="/jobs/recordInfo", veo=False)
443
+ url = urls[0] if urls else ""
419
444
  return _download_or_write(image_bytes, url, destination, routed.card)
420
445
 
421
446
 
@@ -480,7 +505,11 @@ def _video_endpoint(card: Mapping[str, Any]) -> str:
480
505
  if not _model(card):
481
506
  raise MediaGenerationError("FAL AI requer o identificador da rota/modelo para gerar vídeo.")
482
507
  return f"{base}/{_model(card).lstrip('/')}"
483
- if style in {"openai_compatible", "agnes", "kie"}:
508
+ if style == "openrouter":
509
+ return f"{base}/videos"
510
+ if style == "kie":
511
+ return f"{base}/jobs/createTask"
512
+ if style in {"openai_compatible", "agnes"}:
484
513
  return f"{base}/videos/generations"
485
514
  if style == "replicate":
486
515
  return f"{base}/predictions"
@@ -503,6 +532,19 @@ def _video_request(card: dict[str, Any], prompt: str, image_url: str = "") -> An
503
532
  }
504
533
  if image_url:
505
534
  body["image_url"] = image_url
535
+ if style == "openrouter":
536
+ body["aspect_ratio"] = str(card.get("aspect_ratio") or INTERNAL_VIDEO_ASPECT_RATIO)
537
+ body["resolution"] = str(card.get("video_size") or INTERNAL_VIDEO_SIZE)
538
+ return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
539
+ if style == "kie":
540
+ input_payload: dict[str, Any] = {
541
+ "prompt": body["prompt"],
542
+ "aspect_ratio": str(card.get("aspect_ratio") or INTERNAL_VIDEO_ASPECT_RATIO),
543
+ "resolution": str(card.get("video_size") or INTERNAL_VIDEO_SIZE),
544
+ }
545
+ if image_url:
546
+ input_payload["imageUrls"] = [image_url]
547
+ return requests.post(endpoint, headers=_headers(card), json={"model": _model(card), "input": input_payload}, timeout=180)
506
548
  if style == "heygen":
507
549
  avatar_id = str(card.get("avatar_id") or "").strip()
508
550
  if not avatar_id:
@@ -534,7 +576,11 @@ def _video_result(payload: Mapping[str, Any]) -> tuple[str, str]:
534
576
  raw_output = payload.get("output")
535
577
  if isinstance(raw_output, list) and raw_output:
536
578
  raw_output = raw_output[0]
537
- direct = str(payload.get("video_url") or payload.get("url") or raw_output or "").strip()
579
+ unsigned_urls = payload.get("unsigned_urls")
580
+ if isinstance(unsigned_urls, list) and unsigned_urls:
581
+ direct = str(unsigned_urls[0] or "").strip()
582
+ else:
583
+ direct = str(payload.get("content_url") or payload.get("video_url") or payload.get("url") or raw_output or "").strip()
538
584
  if direct.startswith(("http://", "https://")):
539
585
  return direct, ""
540
586
  result = payload.get("result")
@@ -551,7 +597,7 @@ def _video_result(payload: Mapping[str, Any]) -> tuple[str, str]:
551
597
  direct = str(data.get("video_url") or data.get("url") or "").strip()
552
598
  if direct.startswith(("http://", "https://")):
553
599
  return direct, ""
554
- for key in ("video_id", "id", "request_id", "task_id", "job_id"):
600
+ for key in ("video_id", "id", "request_id", "task_id", "taskId", "job_id"):
555
601
  value = str(data.get(key) or "").strip()
556
602
  if value:
557
603
  return "", value
@@ -576,6 +622,8 @@ def _poll_video(card: Mapping[str, Any], request_id: str, *, attempts: int = 24,
576
622
  endpoint = f"{base}/predictions/{request_id}"
577
623
  elif style == "heygen":
578
624
  endpoint = f"{base}/v3/videos/{request_id}"
625
+ elif style == "openrouter":
626
+ endpoint = f"{base}/videos/{request_id}"
579
627
  else:
580
628
  endpoint = f"{base}/videos/{request_id}"
581
629
  headers = _headers(card, fal=style == "fal_queue")
@@ -627,7 +675,11 @@ def generate_video_for_card(
627
675
  url, request_id = _video_result(routed.payload)
628
676
  if not url and request_id:
629
677
  try:
630
- url = _poll_video(routed.card, request_id)
678
+ if str(routed.card.get("api_style") or media_provider_definition(routed.card.get("provider")).api_style) == "kie":
679
+ urls = _poll_kie_task(routed.card, request_id, endpoint="/jobs/recordInfo", veo=False)
680
+ url = urls[0] if urls else ""
681
+ else:
682
+ url = _poll_video(routed.card, request_id)
631
683
  except ProviderCallError as exc:
632
684
  raise MediaGenerationError(str(exc)) from exc
633
685
  if not url:
@@ -22,6 +22,20 @@ FULL_IA_VIDEO_PROVIDER_CODES = (
22
22
  "huggingface",
23
23
  "inferenceport",
24
24
  "heygen",
25
+ "openrouter",
26
+ )
27
+
28
+ # A KIE disponibiliza o Market por páginas de modelo, sem um endpoint global
29
+ # documentado para listar modelos. Estes slugs são os modelos multimédia
30
+ # suportados pelo contrato unificado jobs/createTask.
31
+ KIE_MEDIA_MODEL_CATALOG = (
32
+ "bytedance/seedream-v4-text-to-image",
33
+ "seedream/4.5-text-to-image",
34
+ "grok-imagine/text-to-image",
35
+ "kling-2.6/text-to-video",
36
+ "grok-imagine/image-to-video",
37
+ "veo3_fast",
38
+ "veo3",
25
39
  )
26
40
 
27
41
  # Mantidos internamente para preservar compatibilidade e controlar o prompt dos providers.
@@ -165,6 +179,16 @@ MEDIA_PROVIDER_CATALOG: tuple[MediaProviderDefinition, ...] = (
165
179
  extra_fields=("avatar_id", "voice_id"),
166
180
  description="Vídeos com avatar, script ou áudio pela API V3 da HeyGen.",
167
181
  ),
182
+ MediaProviderDefinition(
183
+ "openrouter",
184
+ "OpenRouter",
185
+ default_base_url="https://openrouter.ai/api/v1",
186
+ supports_image=True,
187
+ supports_video=True,
188
+ supports_text=True,
189
+ api_style="openrouter",
190
+ description="Gateway unificado para modelos de geração de imagem e vídeo via APIs dedicadas do OpenRouter.",
191
+ ),
168
192
  )
169
193
 
170
194
  _MEDIA_BY_CODE = {item.code: item for item in MEDIA_PROVIDER_CATALOG}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.5.27",
3
+ "version": "0.5.29",
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",