@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 +21 -1
- package/hermes_ui/api_key_tests.py +7 -0
- package/hermes_ui/media_generation.py +57 -5
- package/hermes_ui/media_providers.py +24 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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