@danhachuel/thunderbolt 0.3.18 → 0.3.20

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
@@ -34,7 +34,7 @@ from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEd
34
34
  from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips, list_generated_videos as list_cut_generated_videos, list_runs as list_cut_runs, list_video_files as list_cut_video_files, manifest_bytes as cut_manifest_bytes, store_uploaded_video, zip_run as zip_cut_run
35
35
  from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
36
36
  from hermes_ui.mcp_server import server_status, start_server, stop_server
37
- from hermes_ui.material_sources import material_api_keys, material_source_catalog, selected_material_source, update_material_api_keys
37
+ 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
38
38
  from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_ACTIVE_CARD_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
39
39
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
40
40
  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
@@ -3663,51 +3663,86 @@ def render_google_accounts():
3663
3663
  st.success("Configuração global do YouTube guardada em Contas Google.")
3664
3664
  st.rerun()
3665
3665
 
3666
- def render_material_source_api_keys(settings: dict[str, Any]) -> None:
3667
- st.subheader("Fontes de materiais")
3668
- st.caption("Seleccione a fonte que será usada na pipeline e guarde uma ou mais API keys. Qualidade, endpoints e parâmetros internos são definidos pelo Thunderbolt.")
3669
- source_catalog = material_source_catalog()
3670
- source_codes = [item["code"] for item in source_catalog] + ["local"]
3671
- source_labels = {item["code"]: item["label"] for item in source_catalog} | {"local": "Ficheiros locais"}
3672
- source_help = {item["code"]: item["description"] for item in source_catalog} | {"local": "Usar materiais já existentes no storage local; não requer API key."}
3673
- selected_source = st.selectbox(
3674
- "Fonte de materiais",
3675
- source_codes,
3676
- index=source_codes.index(selected_material_source(settings)) if selected_material_source(settings) in source_codes else 0,
3677
- format_func=lambda value: source_labels.get(value, value),
3678
- key="material_source_selector",
3679
- )
3680
- st.caption(source_help.get(selected_source, ""))
3681
- current_keys = material_api_keys(settings, selected_source)
3682
- row_key = f"material_source_key_rows_{selected_source}"
3683
- row_count = max(len(current_keys), int(st.session_state.get(row_key, len(current_keys) or 1)))
3684
- if selected_source == "local":
3685
- _render_credential_status("", local=True, required=False)
3686
- st.info("A fonte local não usa API key. Os materiais devem existir na pasta configurada do storage.")
3687
- if st.button("Guardar fonte local", type="primary", key="save_local_material_source"):
3688
- settings["video_source"] = selected_source
3689
- write_json("settings.json", settings)
3690
- st.success("Fonte de materiais guardada: ficheiros locais.")
3666
+ def _persist_material_source_cards(settings: dict[str, Any], cards: list[dict[str, Any]], active_card_id: str = "") -> dict[str, Any]:
3667
+ updated = apply_material_source_cards_to_settings(settings, cards, active_card_id)
3668
+ write_json("settings.json", updated)
3669
+ return updated
3670
+
3671
+
3672
+ def _material_source_card_definition(provider: str) -> dict[str, str]:
3673
+ definition = material_source_definition(provider)
3674
+ if definition is not None:
3675
+ return definition
3676
+ return {
3677
+ "code": "local",
3678
+ "label": "Ficheiros locais",
3679
+ "description": "Usar materiais já existentes no storage local; não requer API key.",
3680
+ "legacy_key": "",
3681
+ }
3682
+
3683
+
3684
+ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str, Any]], index: int) -> None:
3685
+ card = normalize_material_card(cards[index], index)
3686
+ cards[index] = card
3687
+ card_id = str(card["id"])
3688
+ provider = str(card.get("provider") or "pexels")
3689
+ definition = _material_source_card_definition(provider)
3690
+ is_local = provider == "local"
3691
+ active_card_id = str(settings.get("material_active_card_id") or "")
3692
+ with st.container(border=True):
3693
+ header_cols = st.columns([3.2, 1.2])
3694
+ with header_cols[0]:
3695
+ st.subheader(definition["label"])
3696
+ st.caption(definition["description"])
3697
+ with header_cols[1]:
3698
+ _render_credential_status("" if is_local else card.get("api_key"), local=is_local, required=not is_local)
3699
+ with st.form(f"material_source_card_form_{card_id}"):
3700
+ content_cols = st.columns(2)
3701
+ with content_cols[0]:
3702
+ if is_local:
3703
+ st.caption("Esta fonte não usa API key.")
3704
+ api_key = ""
3705
+ else:
3706
+ api_key = st.text_input("API Key", value=str(card.get("api_key") or ""), type="password", key=f"material_card_{card_id}_api_key")
3707
+ with content_cols[1]:
3708
+ enabled = st.checkbox("Fonte activa", value=bool(card.get("enabled", True)), key=f"material_card_{card_id}_enabled")
3709
+ selected = st.checkbox(
3710
+ "Usar esta fonte na pipeline",
3711
+ value=active_card_id == card_id,
3712
+ key=f"material_card_{card_id}_selected",
3713
+ )
3714
+ save_card = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"material_card_{card_id}_save")
3715
+ if save_card:
3716
+ cards[index] = {**card, "api_key": str(api_key or "").strip(), "enabled": bool(enabled)}
3717
+ selected_id = card_id if selected and enabled else active_card_id
3718
+ _persist_material_source_cards(settings, cards, selected_id)
3719
+ st.success(f"Fonte {definition['label']} guardada.")
3691
3720
  st.rerun()
3692
- return
3693
- _render_credential_status(any(str(item or "").strip() for item in current_keys))
3694
- with st.form(f"material_api_keys_form_{selected_source}"):
3695
- key_values: list[str] = []
3696
- for index in range(row_count):
3697
- key_values.append(st.text_input(f"API Key {index + 1}", value=current_keys[index] if index < len(current_keys) else "", type="password", key=f"material_api_key_{selected_source}_{index}"))
3698
- save_keys = st.form_submit_button("Guardar fonte e chaves", type="primary", use_container_width=True)
3699
- add_key = st.form_submit_button("Adicionar outra chave", use_container_width=True)
3700
- if add_key:
3701
- st.session_state[row_key] = row_count + 1
3702
- st.rerun()
3703
- if save_keys:
3704
- update_material_api_keys(settings, selected_source, key_values)
3705
- settings["video_source"] = selected_source
3721
+
3722
+
3723
+ def render_material_source_api_keys(settings: dict[str, Any]) -> None:
3724
+ st.subheader("Fontes de Materiais")
3725
+ st.caption("Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.")
3726
+ migrated, changed = ensure_material_source_cards(settings)
3727
+ cards = [dict(item) for item in migrated.get("material_source_cards", [])]
3728
+ if changed:
3706
3729
  write_json("settings.json", settings)
3707
- st.session_state[row_key] = max(1, len(material_api_keys(settings, selected_source)))
3708
- st.success(f"Fonte {source_labels.get(selected_source, selected_source)} guardada com {len(material_api_keys(settings, selected_source))} chave(s).")
3730
+ for index in range(len(cards)):
3731
+ _render_material_source_card(settings, cards, index)
3732
+
3733
+ st.divider()
3734
+ st.markdown("**Adicionar fonte de materiais**")
3735
+ provider_codes = [item["code"] for item in material_source_catalog()] + ["local"]
3736
+ provider_to_add = st.selectbox(
3737
+ "Provedor de materiais",
3738
+ provider_codes,
3739
+ format_func=lambda value: _material_source_card_definition(value)["label"],
3740
+ key="material_new_provider_choice",
3741
+ )
3742
+ if st.button("Configurar Nova Fonte de Materiais", type="primary", use_container_width=True, key="add_material_source_card"):
3743
+ cards.append(new_material_card(provider_to_add, card_id=f"material-{provider_to_add}-{uuid.uuid4().hex[:8]}"))
3744
+ _persist_material_source_cards(settings, cards, str(settings.get("material_active_card_id") or ""))
3709
3745
  st.rerun()
3710
- st.caption(f"{len(current_keys)} chave(s) actualmente guardada(s) para {source_labels.get(selected_source, selected_source)}. As chaves são mantidas no storage local e usadas com rotação interna.")
3711
3746
 
3712
3747
 
3713
3748
  def _api_status_badge(label: str, kind: str = "missing") -> None:
@@ -3853,10 +3888,6 @@ def _render_llm_card(settings: dict[str, Any], cards: list[dict[str, Any]], inde
3853
3888
  test_result = test_llm_provider_card(edited)
3854
3889
  edited["test_result"] = stamp_test_result(test_result)
3855
3890
  _persist_llm_cards(settings, cards, str(settings.get(LLM_ACTIVE_CARD_KEY) or card_id))
3856
- if test_result.get("ok"):
3857
- st.success(f"Teste API: {test_result['message']}")
3858
- else:
3859
- st.error(f"Teste API: {test_result['message']}")
3860
3891
  elif refresh_clicked:
3861
3892
  try:
3862
3893
  from integrations.openai_model_discovery import fetch_openai_compatible_models
@@ -3880,7 +3911,7 @@ def _render_llm_card(settings: dict[str, Any], cards: list[dict[str, Any]], inde
3880
3911
  saved_test = edited.get("test_result") or card.get("test_result")
3881
3912
  if isinstance(saved_test, dict) and saved_test.get("message"):
3882
3913
  if saved_test.get("status") == "success":
3883
- st.success(f"Último teste: {saved_test['message']}")
3914
+ st.success("Último teste: API Key OK")
3884
3915
  else:
3885
3916
  st.error(f"Último teste: {saved_test['message']}")
3886
3917
 
@@ -3931,7 +3962,7 @@ def render_settings():
3931
3962
  key=f"settings_{key}",
3932
3963
  )
3933
3964
 
3934
- api_keys_tab, material_sources_tab, voice_test_tab = render_localized_tabs(["API Keys", "Fontes de Materiais", "Teste de Voz"])
3965
+ api_keys_tab, google_accounts_tab, material_sources_tab, voice_test_tab = render_localized_tabs(["API Keys", "Contas Google", "Fontes de Materiais", "Teste de Voz"])
3935
3966
 
3936
3967
  with api_keys_tab:
3937
3968
  with st.container(border=True):
@@ -4066,8 +4097,11 @@ def render_settings():
4066
4097
  st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
4067
4098
  except Exception as exc:
4068
4099
  st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
4069
- with material_sources_tab:
4070
- render_material_source_api_keys(settings)
4100
+ with google_accounts_tab:
4101
+ render_google_accounts()
4102
+
4103
+ with material_sources_tab:
4104
+ render_material_source_api_keys(settings)
4071
4105
 
4072
4106
  with voice_test_tab:
4073
4107
  st.subheader("Teste de Voz")
@@ -4531,7 +4565,6 @@ def main():
4531
4565
  ]
4532
4566
  settings_items = [
4533
4567
  ("MCP", ":material/hub:", "MCP"),
4534
- ("Contas Google", ":material/account_circle:", "Contas Google"),
4535
4568
  ("Notificações", ":material/notifications:", "Notificações"),
4536
4569
  ("Configuração API", ":material/settings:", "Configuração API"),
4537
4570
  ]
@@ -4589,7 +4622,8 @@ def main():
4589
4622
  "Blueprints": "Blueprints Youtube",
4590
4623
  "Configurações Técnicas": "Configuração API",
4591
4624
  "Models AI": "AI Influencers",
4592
- "Contas Google/YouTube — canais em lote": "Contas Google",
4625
+ "Contas Google/YouTube — canais em lote": "Configuração API",
4626
+ "Contas Google": "Configuração API",
4593
4627
  }
4594
4628
  all_children = [item for items in groups.values() for item in items]
4595
4629
  valid_targets = {item[0] for item in top_pages + all_children}
@@ -922,3 +922,109 @@ __all__ = [
922
922
  "language_label", "ui_language_menu_label", "language_locale", "language_option_codes", "language_option_labels",
923
923
  "ui_text", "video_language_label", "video_language_options",
924
924
  ]
925
+
926
+
927
+ _MATERIAL_SOURCE_TRANSLATIONS: dict[str, dict[str, str]] = {
928
+ "pt": {
929
+ "Fontes de Materiais": "Fontes de Materiais",
930
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.",
931
+ "Adicionar fonte de materiais": "Adicionar fonte de materiais",
932
+ "Provedor de materiais": "Provedor de materiais",
933
+ "Configurar Nova Fonte de Materiais": "Configurar Nova Fonte de Materiais",
934
+ "Fonte activa": "Fonte activa",
935
+ "Usar esta fonte na pipeline": "Usar esta fonte na pipeline",
936
+ "Esta fonte não usa API key.": "Esta fonte não usa API key.",
937
+ },
938
+ "en": {
939
+ "Fontes de Materiais": "Media Sources",
940
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Configure each provider in an independent card. You can repeat the same provider to store multiple API keys; the selected source will be used by the pipeline.",
941
+ "Adicionar fonte de materiais": "Add media source",
942
+ "Provedor de materiais": "Media provider",
943
+ "Configurar Nova Fonte de Materiais": "Configure New Media Source",
944
+ "Fonte activa": "Source active",
945
+ "Usar esta fonte na pipeline": "Use this source in the pipeline",
946
+ "Esta fonte não usa API key.": "This source does not use an API key.",
947
+ },
948
+ "zh": {
949
+ "Fontes de Materiais": "素材来源",
950
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "在独立卡片中配置每个提供商。可以重复同一提供商以保存多个 API 密钥;所选来源将由流水线使用。",
951
+ "Adicionar fonte de materiais": "添加素材来源",
952
+ "Provedor de materiais": "素材提供商",
953
+ "Configurar Nova Fonte de Materiais": "配置新的素材来源",
954
+ "Fonte activa": "来源已启用",
955
+ "Usar esta fonte na pipeline": "在流水线中使用此来源",
956
+ "Esta fonte não usa API key.": "此来源不使用 API 密钥。",
957
+ },
958
+ "de": {
959
+ "Fontes de Materiais": "Medienquellen",
960
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Konfigurieren Sie jeden Anbieter in einer eigenen Karte. Derselbe Anbieter kann für mehrere API-Schlüssel wiederholt werden; die ausgewählte Quelle wird von der Pipeline verwendet.",
961
+ "Adicionar fonte de materiais": "Medienquelle hinzufügen",
962
+ "Provedor de materiais": "Medienanbieter",
963
+ "Configurar Nova Fonte de Materiais": "Neue Medienquelle konfigurieren",
964
+ "Fonte activa": "Quelle aktiv",
965
+ "Usar esta fonte na pipeline": "Diese Quelle in der Pipeline verwenden",
966
+ "Esta fonte não usa API key.": "Diese Quelle verwendet keinen API-Schlüssel.",
967
+ },
968
+ "vi": {
969
+ "Fontes de Materiais": "Nguồn phương tiện",
970
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Định cấu hình từng nhà cung cấp trong một thẻ riêng. Có thể lặp lại cùng nhà cung cấp để lưu nhiều API key; nguồn được chọn sẽ được quy trình sử dụng.",
971
+ "Adicionar fonte de materiais": "Thêm nguồn phương tiện",
972
+ "Provedor de materiais": "Nhà cung cấp phương tiện",
973
+ "Configurar Nova Fonte de Materiais": "Định cấu hình nguồn phương tiện mới",
974
+ "Fonte activa": "Nguồn đang hoạt động",
975
+ "Usar esta fonte na pipeline": "Dùng nguồn này trong quy trình",
976
+ "Esta fonte não usa API key.": "Nguồn này không dùng API key.",
977
+ },
978
+ "tr": {
979
+ "Fontes de Materiais": "Medya kaynakları",
980
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Her sağlayıcıyı bağımsız bir kartta yapılandırın. Birden fazla API anahtarı saklamak için aynı sağlayıcıyı tekrarlayabilirsiniz; seçilen kaynak akış tarafından kullanılacaktır.",
981
+ "Adicionar fonte de materiais": "Medya kaynağı ekle",
982
+ "Provedor de materiais": "Medya sağlayıcısı",
983
+ "Configurar Nova Fonte de Materiais": "Yeni medya kaynağını yapılandır",
984
+ "Fonte activa": "Kaynak etkin",
985
+ "Usar esta fonte na pipeline": "Bu kaynağı akışta kullan",
986
+ "Esta fonte não usa API key.": "Bu kaynak API anahtarı kullanmaz.",
987
+ },
988
+ "ru": {
989
+ "Fontes de Materiais": "Источники материалов",
990
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Настройте каждого провайдера в отдельной карточке. Один и тот же провайдер можно повторить для хранения нескольких API-ключей; выбранный источник будет использоваться конвейером.",
991
+ "Adicionar fonte de materiais": "Добавить источник материалов",
992
+ "Provedor de materiais": "Провайдер материалов",
993
+ "Configurar Nova Fonte de Materiais": "Настроить новый источник материалов",
994
+ "Fonte activa": "Источник активен",
995
+ "Usar esta fonte na pipeline": "Использовать этот источник в конвейере",
996
+ "Esta fonte não usa API key.": "Этот источник не использует API-ключ.",
997
+ },
998
+ "es": {
999
+ "Fontes de Materiais": "Fuentes de medios",
1000
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Configura cada proveedor en una tarjeta independiente. Puedes repetir el mismo proveedor para guardar varias claves API; la fuente seleccionada será utilizada por el flujo.",
1001
+ "Adicionar fonte de materiais": "Añadir fuente de medios",
1002
+ "Provedor de materiais": "Proveedor de medios",
1003
+ "Configurar Nova Fonte de Materiais": "Configurar nueva fuente de medios",
1004
+ "Fonte activa": "Fuente activa",
1005
+ "Usar esta fonte na pipeline": "Usar esta fuente en el flujo",
1006
+ "Esta fonte não usa API key.": "Esta fuente no utiliza una clave API.",
1007
+ },
1008
+ "id": {
1009
+ "Fontes de Materiais": "Sumber media",
1010
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Konfigurasikan setiap penyedia dalam kartu terpisah. Penyedia yang sama dapat diulang untuk menyimpan beberapa kunci API; sumber yang dipilih akan digunakan oleh pipeline.",
1011
+ "Adicionar fonte de materiais": "Tambah sumber media",
1012
+ "Provedor de materiais": "Penyedia media",
1013
+ "Configurar Nova Fonte de Materiais": "Konfigurasikan Sumber Media Baru",
1014
+ "Fonte activa": "Sumber aktif",
1015
+ "Usar esta fonte na pipeline": "Gunakan sumber ini di pipeline",
1016
+ "Esta fonte não usa API key.": "Sumber ini tidak menggunakan kunci API.",
1017
+ },
1018
+ "it": {
1019
+ "Fontes de Materiais": "Fonti multimediali",
1020
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Configura ogni provider in una scheda indipendente. Puoi ripetere lo stesso provider per conservare più API key; la fonte selezionata sarà usata dalla pipeline.",
1021
+ "Adicionar fonte de materiais": "Aggiungi fonte multimediale",
1022
+ "Provedor de materiais": "Provider multimediale",
1023
+ "Configurar Nova Fonte de Materiais": "Configura nuova fonte multimediale",
1024
+ "Fonte activa": "Fonte attiva",
1025
+ "Usar esta fonte na pipeline": "Usa questa fonte nella pipeline",
1026
+ "Esta fonte não usa API key.": "Questa fonte non usa una API key.",
1027
+ },
1028
+ }
1029
+ for _language_code, _material_source_translation in _MATERIAL_SOURCE_TRANSLATIONS.items():
1030
+ UI_TRANSLATIONS[_language_code].update(_material_source_translation)
@@ -9,12 +9,11 @@ from __future__ import annotations
9
9
  from dataclasses import dataclass
10
10
  from datetime import datetime, timezone
11
11
  from typing import Any, Mapping
12
- from urllib.parse import urlsplit, urlunsplit
13
-
14
12
  from integrations.openai_model_discovery import (
15
13
  DEFAULT_NVIDIA_NIM_BASE_URL,
16
- ModelDiscoveryError,
14
+ OpenAICompatibleAPIError,
17
15
  fetch_openai_compatible_models,
16
+ validate_openai_compatible_api_key,
18
17
  )
19
18
 
20
19
 
@@ -357,14 +356,6 @@ def apply_llm_cards_to_settings(
357
356
  return result
358
357
 
359
358
 
360
- def _safe_url(value: str) -> str:
361
- try:
362
- parsed = urlsplit(str(value or ""))
363
- return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
364
- except ValueError:
365
- return "endpoint configurado"
366
-
367
-
368
359
  def _redact(text: Any, secrets: tuple[str, ...]) -> str:
369
360
  message = str(text or "")
370
361
  for secret in secrets:
@@ -386,11 +377,12 @@ def test_llm_provider_card(card: Mapping[str, Any]) -> dict[str, Any]:
386
377
  return {"ok": False, "status": "error", "message": "Missing configuration — introduza a Base URL antes do teste."}
387
378
  if not base_url:
388
379
  return {"ok": False, "status": "error", "message": "Missing configuration — este provider não tem endpoint configurado."}
389
- if not definition.supports_model_discovery:
390
- return {"ok": False, "status": "error", "message": "Teste automático não suportado para este protocolo; verifique os campos do provider."}
380
+ model = str(normalized.get("model") or "").strip()
381
+ if not model:
382
+ return {"ok": False, "status": "error", "message": "Missing configuration — introduza o modelo antes do teste."}
391
383
  try:
392
- models = fetch_openai_compatible_models(api_key, base_url)
393
- except ModelDiscoveryError as exc:
384
+ validate_openai_compatible_api_key(api_key, base_url, model)
385
+ except OpenAICompatibleAPIError as exc:
394
386
  return {
395
387
  "ok": False,
396
388
  "status": "error",
@@ -401,7 +393,7 @@ def test_llm_provider_card(card: Mapping[str, Any]) -> dict[str, Any]:
401
393
  return {
402
394
  "ok": True,
403
395
  "status": "success",
404
- "message": f"API OK — {_safe_url(base_url)} respondeu com {len(models)} modelo(s).",
396
+ "message": "API Key OK",
405
397
  }
406
398
 
407
399
 
@@ -1,8 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
+ from uuid import uuid4
4
5
 
5
6
 
7
+ MATERIAL_CARDS_KEY = "material_source_cards"
8
+ MATERIAL_ACTIVE_CARD_KEY = "material_active_card_id"
9
+
6
10
  MATERIAL_SOURCE_CATALOG: tuple[dict[str, str], ...] = (
7
11
  {"code": "pexels", "label": "Pexels", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pexels_api_keys"},
8
12
  {"code": "pixabay", "label": "Pixabay", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pixabay_api_keys"},
@@ -13,6 +17,9 @@ MATERIAL_SOURCE_CATALOG: tuple[dict[str, str], ...] = (
13
17
  )
14
18
 
15
19
 
20
+ _SOURCE_BY_CODE = {item["code"]: item for item in MATERIAL_SOURCE_CATALOG}
21
+
22
+
16
23
  def _as_key_list(value: Any) -> list[str]:
17
24
  if isinstance(value, list):
18
25
  values = value
@@ -32,12 +39,124 @@ def material_source_catalog() -> list[dict[str, str]]:
32
39
  return [dict(item) for item in MATERIAL_SOURCE_CATALOG]
33
40
 
34
41
 
35
- def material_api_keys(settings: dict[str, Any], source: str) -> list[str]:
42
+ def material_source_definition(source: Any) -> dict[str, str] | None:
43
+ return _SOURCE_BY_CODE.get(str(source or "").strip().lower())
44
+
45
+
46
+ def _new_card(provider: str, api_key: str = "", *, card_id: str | None = None) -> dict[str, Any]:
47
+ return {
48
+ "id": card_id or f"material-{provider}-{uuid4().hex[:8]}",
49
+ "provider": provider,
50
+ "api_key": str(api_key or "").strip(),
51
+ "enabled": True,
52
+ }
53
+
54
+
55
+ def normalize_material_card(card: Any, index: int = 0) -> dict[str, Any]:
56
+ raw = card if isinstance(card, dict) else {}
57
+ provider = str(raw.get("provider") or raw.get("source") or "pexels").strip().lower()
58
+ if provider not in _SOURCE_BY_CODE and provider != "local":
59
+ provider = "pexels"
60
+ card_id = str(raw.get("id") or f"material-{provider}-{index}").strip()
61
+ return {
62
+ "id": card_id or f"material-{provider}-{index}",
63
+ "provider": provider,
64
+ "api_key": str(raw.get("api_key") or raw.get("key") or "").strip() if provider != "local" else "",
65
+ "enabled": bool(raw.get("enabled", True)),
66
+ }
67
+
68
+
69
+ def _cards_key_mapping(cards: list[dict[str, Any]]) -> dict[str, list[str]]:
70
+ mapping = {item["code"]: [] for item in MATERIAL_SOURCE_CATALOG}
71
+ for index, raw_card in enumerate(cards):
72
+ card = normalize_material_card(raw_card, index)
73
+ provider = card["provider"]
74
+ api_key = str(card.get("api_key") or "").strip()
75
+ if card.get("enabled", True) and provider in mapping and api_key and api_key not in mapping[provider]:
76
+ mapping[provider].append(api_key)
77
+ return mapping
78
+
79
+
80
+ def _sync_legacy_material_keys(settings: dict[str, Any], mapping: dict[str, list[str]]) -> None:
81
+ settings["material_api_keys"] = mapping
82
+ for item in MATERIAL_SOURCE_CATALOG:
83
+ settings[item["legacy_key"]] = list(mapping.get(item["code"], []))
84
+
85
+
86
+ def ensure_material_source_cards(settings: dict[str, Any]) -> tuple[dict[str, Any], bool]:
87
+ """Migrate legacy per-source key lists into stable independent material cards."""
88
+ original = settings.get(MATERIAL_CARDS_KEY)
89
+ changed = False
90
+ if isinstance(original, list) and original:
91
+ cards = [normalize_material_card(item, index) for index, item in enumerate(original)]
92
+ changed = cards != original
93
+ else:
94
+ cards: list[dict[str, Any]] = []
95
+ for item in MATERIAL_SOURCE_CATALOG:
96
+ for key in material_api_keys(settings, item["code"], _ignore_cards=True):
97
+ cards.append(_new_card(item["code"], key, card_id=f"material-{item['code']}-{len(cards)}"))
98
+ if not cards:
99
+ selected = selected_material_source(settings)
100
+ cards.append(_new_card(selected if selected in _SOURCE_BY_CODE else "pexels", card_id="material-default-0"))
101
+ changed = True
102
+
103
+ settings[MATERIAL_CARDS_KEY] = cards
104
+ active_id = str(settings.get(MATERIAL_ACTIVE_CARD_KEY) or "").strip()
105
+ valid_ids = {str(card["id"]) for card in cards}
106
+ if active_id not in valid_ids:
107
+ selected_source = selected_material_source(settings)
108
+ matching = next((card for card in cards if card["provider"] == selected_source), None)
109
+ active_id = str(matching["id"]) if matching else str(cards[0]["id"])
110
+ settings[MATERIAL_ACTIVE_CARD_KEY] = active_id
111
+ changed = True
112
+
113
+ mapping = _cards_key_mapping(cards)
114
+ if settings.get("material_api_keys") != mapping:
115
+ _sync_legacy_material_keys(settings, mapping)
116
+ changed = True
117
+ return settings, changed
118
+
119
+
120
+ def ensure_material_source_cards_for_ui(settings: dict[str, Any]) -> list[dict[str, Any]]:
121
+ migrated, _ = ensure_material_source_cards(settings)
122
+ return [dict(item) for item in migrated.get(MATERIAL_CARDS_KEY, [])]
123
+
124
+
125
+ def new_material_card(provider: str, *, card_id: str | None = None) -> dict[str, Any]:
126
+ code = str(provider or "").strip().lower()
127
+ if code not in _SOURCE_BY_CODE:
128
+ raise ValueError("Fonte de materiais inválida.")
129
+ return _new_card(code, card_id=card_id)
130
+
131
+
132
+ def apply_material_source_cards_to_settings(
133
+ settings: dict[str, Any],
134
+ cards: list[dict[str, Any]],
135
+ active_card_id: str = "",
136
+ ) -> dict[str, Any]:
137
+ normalized_cards = [normalize_material_card(item, index) for index, item in enumerate(cards)]
138
+ if not normalized_cards:
139
+ normalized_cards = [_new_card("pexels", card_id="material-default-0")]
140
+ settings[MATERIAL_CARDS_KEY] = normalized_cards
141
+ selected_card = next((card for card in normalized_cards if str(card["id"]) == str(active_card_id)), None)
142
+ if selected_card is None:
143
+ selected_card = next((card for card in normalized_cards if card.get("enabled", True)), normalized_cards[0])
144
+ settings[MATERIAL_ACTIVE_CARD_KEY] = str(selected_card["id"])
145
+ settings["video_source"] = str(selected_card["provider"])
146
+ _sync_legacy_material_keys(settings, _cards_key_mapping(normalized_cards))
147
+ return settings
148
+
149
+
150
+ def material_api_keys(settings: dict[str, Any], source: str, _ignore_cards: bool = False) -> list[str]:
36
151
  code = str(source or "").strip().lower()
152
+ if not _ignore_cards:
153
+ cards = settings.get(MATERIAL_CARDS_KEY)
154
+ if isinstance(cards, list) and cards:
155
+ return _cards_key_mapping(cards).get(code, [])
37
156
  saved = settings.get("material_api_keys", {})
38
157
  if isinstance(saved, dict) and code in saved:
39
158
  return _as_key_list(saved.get(code))
40
- legacy_key = next((item["legacy_key"] for item in MATERIAL_SOURCE_CATALOG if item["code"] == code), f"{code}_api_keys")
159
+ legacy_key = _SOURCE_BY_CODE.get(code, {}).get("legacy_key", f"{code}_api_keys")
41
160
  return _as_key_list(settings.get(legacy_key, ""))
42
161
 
43
162
 
@@ -47,18 +166,16 @@ def all_material_api_keys(settings: dict[str, Any]) -> dict[str, list[str]]:
47
166
 
48
167
  def update_material_api_keys(settings: dict[str, Any], source: str, keys: list[str]) -> dict[str, Any]:
49
168
  code = str(source or "").strip().lower()
50
- if code not in {item["code"] for item in MATERIAL_SOURCE_CATALOG}:
169
+ if code not in _SOURCE_BY_CODE:
51
170
  raise ValueError("Fonte de materiais inválida.")
52
171
  cleaned = _as_key_list(keys)
53
172
  mapping = all_material_api_keys(settings)
54
173
  mapping[code] = cleaned
55
- settings["material_api_keys"] = mapping
56
- source_entry = next(item for item in MATERIAL_SOURCE_CATALOG if item["code"] == code)
57
- settings[source_entry["legacy_key"]] = cleaned
174
+ _sync_legacy_material_keys(settings, mapping)
58
175
  return settings
59
176
 
60
177
 
61
178
  def selected_material_source(settings: dict[str, Any]) -> str:
62
179
  source = str(settings.get("video_source") or "pexels").strip().lower()
63
- valid = {item["code"] for item in MATERIAL_SOURCE_CATALOG} | {"local"}
180
+ valid = set(_SOURCE_BY_CODE) | {"local"}
64
181
  return source if source in valid else "pexels"
@@ -16,6 +16,10 @@ class ModelDiscoveryError(ValueError):
16
16
  """Raised when an OpenAI-compatible model list cannot be loaded safely."""
17
17
 
18
18
 
19
+ class OpenAICompatibleAPIError(ValueError):
20
+ """Raised when an authenticated OpenAI-compatible API check fails."""
21
+
22
+
19
23
  def models_endpoint(base_url: str) -> str:
20
24
  """Return the ``/models`` endpoint for an OpenAI-compatible base URL."""
21
25
  value = str(base_url or "").strip().rstrip("/")
@@ -26,6 +30,56 @@ def models_endpoint(base_url: str) -> str:
26
30
  return f"{value}/models"
27
31
 
28
32
 
33
+ def chat_completions_endpoint(base_url: str) -> str:
34
+ """Return the Chat Completions endpoint for an OpenAI-compatible base URL."""
35
+ value = str(base_url or "").strip().rstrip("/")
36
+ if not value:
37
+ raise OpenAICompatibleAPIError("Informe a Base URL antes do teste.")
38
+ if value.endswith("/chat/completions"):
39
+ return value
40
+ return f"{value}/chat/completions"
41
+
42
+
43
+ def validate_openai_compatible_api_key(
44
+ api_key: str,
45
+ base_url: str,
46
+ model: str,
47
+ *,
48
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
49
+ ) -> None:
50
+ """Validate credentials with one minimal authenticated Chat Completions call."""
51
+ token = str(api_key or "").strip()
52
+ model_id = str(model or "").strip()
53
+ if not token:
54
+ raise OpenAICompatibleAPIError("Informe a API key antes do teste.")
55
+ if not model_id:
56
+ raise OpenAICompatibleAPIError("Informe o modelo antes do teste.")
57
+
58
+ endpoint = chat_completions_endpoint(base_url)
59
+ headers = {
60
+ "Accept": "application/json",
61
+ "Authorization": f"Bearer {token}",
62
+ "Content-Type": "application/json",
63
+ }
64
+ payload = {
65
+ "model": model_id,
66
+ "messages": [{"role": "user", "content": "Responda apenas OK."}],
67
+ "max_tokens": 1,
68
+ "temperature": 0,
69
+ }
70
+ try:
71
+ response = requests.post(endpoint, headers=headers, json=payload, timeout=timeout)
72
+ except requests.RequestException as exc:
73
+ raise OpenAICompatibleAPIError(f"Não foi possível testar {endpoint}: {exc}") from exc
74
+
75
+ if response.status_code >= 400:
76
+ if response.status_code in (401, 403):
77
+ raise OpenAICompatibleAPIError(
78
+ f"O endpoint recusou a API key (HTTP {response.status_code}). Verifique a API key."
79
+ )
80
+ raise OpenAICompatibleAPIError(f"O endpoint de teste devolveu HTTP {response.status_code}.")
81
+
82
+
29
83
  def normalize_model_ids(payload: Any) -> list[str]:
30
84
  """Extract unique model IDs from the standard OpenAI response shape."""
31
85
  if isinstance(payload, dict):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
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",