@danhachuel/thunderbolt 0.3.12 → 0.3.14

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,6 +34,7 @@ from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips,
34
34
  from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
35
35
  from hermes_ui.mcp_server import server_status, start_server, stop_server
36
36
  from hermes_ui.material_sources import material_api_keys, material_source_catalog, selected_material_source, update_material_api_keys
37
+ 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
37
38
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
38
39
  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
39
40
  from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
@@ -54,7 +55,7 @@ from integrations.youtube_direct_credentials import delete_credentials_document,
54
55
  from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels, loopback_redirect_uri
55
56
  from integrations.local_runtime import MoneyPrinterRuntime
56
57
  from integrations.moneyprinter_config import sync_moneyprinter_config
57
- from integrations.openai_model_discovery import DEFAULT_NVIDIA_NIM_BASE_URL, ModelDiscoveryError, fetch_openai_compatible_models
58
+ from integrations.openai_model_discovery import DEFAULT_NVIDIA_NIM_BASE_URL
58
59
 
59
60
  DEFAULT_UI_LANGUAGE = "en"
60
61
 
@@ -206,6 +207,15 @@ st.markdown("""
206
207
  [data-testid="stRadio"] label { border:1px solid color-mix(in srgb, currentColor 18%, transparent); background:color-mix(in srgb, currentColor 4%, transparent); }
207
208
  [data-testid="stRadio"] label:has(input:checked) { border-color:var(--tb-gold); background:rgba(197,155,85,.24); }
208
209
  [data-testid="stRadio"] label:has(input:checked) { background:linear-gradient(145deg, color-mix(in srgb, var(--tb-gold) 24%, transparent), color-mix(in srgb, currentColor 6%, transparent)); }
210
+ .tb-api-status { display:inline-flex; align-items:center; gap:.38rem; margin:.08rem 0 .55rem; padding:.18rem .55rem; border-radius:999px; font-size:.78rem; font-weight:700; line-height:1.2; border:1px solid rgba(128,128,128,.24); }
211
+ .tb-api-status__dot { display:inline-flex; align-items:center; justify-content:center; width:1rem; height:1rem; border-radius:50%; font-size:.68rem; font-weight:800; }
212
+ .tb-api-status--missing { color:#9a6700; background:rgba(245,158,11,.16); border-color:rgba(245,158,11,.45); }
213
+ .tb-api-status--missing .tb-api-status__dot { color:#fff; background:#d99000; }
214
+ .tb-api-status--local { color:#2563eb; background:rgba(59,130,246,.10); border-color:rgba(59,130,246,.35); }
215
+ .tb-api-status--ready { color:#15803d; background:rgba(34,197,94,.12); border-color:rgba(34,197,94,.36); }
216
+ .tb-api-status--ready .tb-api-status__dot { color:#fff; background:#16a34a; }
217
+ .tb-api-status--error { color:#b91c1c; background:rgba(239,68,68,.10); border-color:rgba(239,68,68,.34); }
218
+ .tb-api-status--error .tb-api-status__dot { color:#fff; background:#dc2626; }
209
219
  [data-testid="stStatusWidget"] { border-color:rgba(128,128,128,.28) !important; background:rgba(128,128,128,.06) !important; }
210
220
  [data-testid="stStatusWidget"] { border-color:color-mix(in srgb, currentColor 18%, transparent) !important; background:color-mix(in srgb, currentColor 4%, transparent) !important; }
211
221
  /* O menu de idiomas usa layout nativo da aplicação; o toolbar do Streamlit não é alterado. */
@@ -3671,6 +3681,7 @@ def render_material_source_api_keys(settings: dict[str, Any]) -> None:
3671
3681
  row_key = f"material_source_key_rows_{selected_source}"
3672
3682
  row_count = max(len(current_keys), int(st.session_state.get(row_key, len(current_keys) or 1)))
3673
3683
  if selected_source == "local":
3684
+ _render_credential_status("", local=True, required=False)
3674
3685
  st.info("A fonte local não usa API key. Os materiais devem existir na pasta configurada do storage.")
3675
3686
  if st.button("Guardar fonte local", type="primary", key="save_local_material_source"):
3676
3687
  settings["video_source"] = selected_source
@@ -3678,6 +3689,7 @@ def render_material_source_api_keys(settings: dict[str, Any]) -> None:
3678
3689
  st.success("Fonte de materiais guardada: ficheiros locais.")
3679
3690
  st.rerun()
3680
3691
  return
3692
+ _render_credential_status(any(str(item or "").strip() for item in current_keys))
3681
3693
  with st.form(f"material_api_keys_form_{selected_source}"):
3682
3694
  key_values: list[str] = []
3683
3695
  for index in range(row_count):
@@ -3697,6 +3709,199 @@ def render_material_source_api_keys(settings: dict[str, Any]) -> None:
3697
3709
  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.")
3698
3710
 
3699
3711
 
3712
+ def _api_status_badge(label: str, kind: str = "missing") -> None:
3713
+ """Render a compact status chip that remains legible in both native themes."""
3714
+ safe_kind = kind if kind in {"missing", "local", "ready", "error"} else "missing"
3715
+ marker = "!" if safe_kind == "missing" else "✓" if safe_kind == "ready" else "×" if safe_kind == "error" else "•"
3716
+ visible_label = ui_text(label, current_ui_language())
3717
+ st.markdown(
3718
+ f'<span class="tb-api-status tb-api-status--{safe_kind}"><span class="tb-api-status__dot">{marker}</span>{visible_label}</span>',
3719
+ unsafe_allow_html=True,
3720
+ )
3721
+
3722
+
3723
+ def _credential_status(value: Any, *, local: bool = False, required: bool = True) -> tuple[str, str]:
3724
+ if local:
3725
+ return "local", "Local / sem API key"
3726
+ if required and not str(value or "").strip():
3727
+ return "missing", "Missing key"
3728
+ return "ready", "Configured"
3729
+
3730
+
3731
+ def _render_credential_status(value: Any, *, local: bool = False, required: bool = True) -> None:
3732
+ kind, label = _credential_status(value, local=local, required=required)
3733
+ _api_status_badge(label, kind)
3734
+
3735
+
3736
+ def _llm_card_config_status(card: dict[str, Any]) -> tuple[str, str]:
3737
+ definition = provider_definition(card.get("provider"))
3738
+ if definition.local:
3739
+ return "local", "Local / sem API key"
3740
+ if definition.requires_api_key and not str(card.get("api_key") or "").strip():
3741
+ return "missing", "Missing key"
3742
+ if definition.show_base_url and not str(card.get("base_url") or "").strip():
3743
+ return "missing", "Missing configuration"
3744
+ if not str(card.get("model") or "").strip():
3745
+ return "missing", "Missing configuration"
3746
+ return "ready", "Configured"
3747
+
3748
+
3749
+ def _persist_llm_cards(settings: dict[str, Any], cards: list[dict[str, Any]], active_id: str = "") -> dict[str, Any]:
3750
+ updated = apply_llm_cards_to_settings(settings, cards, active_id)
3751
+ write_json("settings.json", updated)
3752
+ return updated
3753
+
3754
+
3755
+ def _render_llm_card(settings: dict[str, Any], cards: list[dict[str, Any]], index: int) -> None:
3756
+ card = normalize_llm_card(cards[index], index)
3757
+ cards[index] = card
3758
+ card_id = str(card["id"])
3759
+ definition = provider_definition(card.get("provider"))
3760
+ with st.container(border=True):
3761
+ header_cols = st.columns([3.2, 1.2])
3762
+ with header_cols[0]:
3763
+ st.subheader(definition.label)
3764
+ if definition.description:
3765
+ st.caption(definition.description)
3766
+ with header_cols[1]:
3767
+ status_kind, status_label = _llm_card_config_status(card)
3768
+ _api_status_badge(status_label, status_kind)
3769
+ with st.form(f"llm_card_form_{card_id}"):
3770
+ key_col, model_col = st.columns(2)
3771
+ with key_col:
3772
+ api_key = card.get("api_key", "")
3773
+ if definition.requires_api_key:
3774
+ api_key = st.text_input("API key", value=str(api_key or ""), type="password", key=f"llm_card_{card_id}_api_key")
3775
+ else:
3776
+ st.caption("Este provider não exige API key.")
3777
+ api_key = ""
3778
+ with model_col:
3779
+ model_catalog = st.session_state.get(f"llm_model_catalog_{card_id}", [])
3780
+ current_model = str(card.get("model") or "")
3781
+ if model_catalog:
3782
+ manual_model = "__manual_model__"
3783
+ options = [manual_model, *[str(item) for item in model_catalog]]
3784
+ selected = st.selectbox(
3785
+ "Modelo",
3786
+ options,
3787
+ index=options.index(current_model) if current_model in options else 0,
3788
+ format_func=lambda value: "Escrever modelo manualmente" if value == manual_model else value,
3789
+ key=f"llm_card_{card_id}_model_select",
3790
+ )
3791
+ model = st.text_input("Modelo manual", value=current_model if selected == manual_model and current_model not in model_catalog else "", key=f"llm_card_{card_id}_model_manual") if selected == manual_model else selected
3792
+ else:
3793
+ model = st.text_input("Modelo", value=current_model, help="ID do modelo usado pelo endpoint Chat Completions.", key=f"llm_card_{card_id}_model")
3794
+ if definition.show_base_url:
3795
+ base_url = st.text_input(
3796
+ "Base URL",
3797
+ value=str(card.get("base_url") or definition.default_base_url),
3798
+ help="Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.",
3799
+ key=f"llm_card_{card_id}_base_url",
3800
+ )
3801
+ else:
3802
+ base_url = str(card.get("base_url") or definition.default_base_url)
3803
+ if base_url:
3804
+ st.caption(f"Endpoint gerido pelo provider: {base_url}")
3805
+ extra_values: dict[str, str] = {}
3806
+ if definition.extra_fields:
3807
+ extra_cols = st.columns(len(definition.extra_fields))
3808
+ for extra_col, field_name in zip(extra_cols, definition.extra_fields):
3809
+ with extra_col:
3810
+ extra_values[field_name] = st.text_input(
3811
+ field_name.replace("_", " ").title(),
3812
+ value=str(card.get(field_name) or ""),
3813
+ key=f"llm_card_{card_id}_{field_name}",
3814
+ )
3815
+ enabled = st.checkbox("Provider activo", value=bool(card.get("enabled", True)), key=f"llm_card_{card_id}_enabled")
3816
+ primary_llm = st.checkbox(
3817
+ "LLM principal",
3818
+ value=str(settings.get(LLM_ACTIVE_CARD_KEY) or "") == card_id,
3819
+ help="Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.",
3820
+ key=f"llm_card_{card_id}_primary",
3821
+ )
3822
+ telegram_llm = st.checkbox(
3823
+ "LLM Telegram",
3824
+ value=bool(card.get("telegram_llm", False)),
3825
+ help="Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.",
3826
+ key=f"llm_card_{card_id}_telegram",
3827
+ )
3828
+ action_cols = st.columns([1.35, 1.35, 1.1])
3829
+ with action_cols[0]:
3830
+ test_clicked = st.form_submit_button("Testar chamada API", use_container_width=True)
3831
+ with action_cols[1]:
3832
+ refresh_clicked = st.form_submit_button("Consultar modelos", use_container_width=True)
3833
+ with action_cols[2]:
3834
+ save_clicked = st.form_submit_button("Salvar", type="primary", use_container_width=True)
3835
+ remove_clicked = False
3836
+ if definition.code != "openai":
3837
+ remove_clicked = st.form_submit_button("Remover cartão", use_container_width=True)
3838
+
3839
+ edited = dict(card)
3840
+ edited.update({"api_key": str(api_key or "").strip(), "model": str(model or "").strip(), "base_url": str(base_url or "").strip(), "enabled": bool(enabled), "telegram_llm": bool(telegram_llm), **extra_values})
3841
+ cards[index] = edited
3842
+ if test_clicked:
3843
+ test_result = test_llm_provider_card(edited)
3844
+ edited["test_result"] = stamp_test_result(test_result)
3845
+ _persist_llm_cards(settings, cards, str(settings.get(LLM_ACTIVE_CARD_KEY) or card_id))
3846
+ if test_result.get("ok"):
3847
+ st.success(f"Teste API: {test_result['message']}")
3848
+ else:
3849
+ st.error(f"Teste API: {test_result['message']}")
3850
+ elif refresh_clicked:
3851
+ try:
3852
+ from integrations.openai_model_discovery import fetch_openai_compatible_models
3853
+ discovered = fetch_openai_compatible_models(edited.get("api_key", ""), edited.get("base_url", ""))
3854
+ st.session_state[f"llm_model_catalog_{card_id}"] = discovered
3855
+ _persist_llm_cards(settings, cards, str(settings.get(LLM_ACTIVE_CARD_KEY) or card_id))
3856
+ st.success(f"{len(discovered)} modelo(s) disponíveis neste endpoint.")
3857
+ except Exception:
3858
+ st.error("Não foi possível consultar os modelos deste endpoint.")
3859
+ elif save_clicked:
3860
+ active_id = card_id if primary_llm and enabled else str(settings.get(LLM_ACTIVE_CARD_KEY) or "")
3861
+ _persist_llm_cards(settings, cards, active_id)
3862
+ st.success("Cartão LLM guardado.")
3863
+ st.rerun()
3864
+ elif remove_clicked:
3865
+ remaining = [item for item in cards if str(item.get("id")) != card_id]
3866
+ _persist_llm_cards(settings, remaining, str(settings.get(LLM_ACTIVE_CARD_KEY) or ""))
3867
+ st.success("Cartão LLM removido.")
3868
+ st.rerun()
3869
+
3870
+ saved_test = edited.get("test_result") or card.get("test_result")
3871
+ if isinstance(saved_test, dict) and saved_test.get("message"):
3872
+ if saved_test.get("status") == "success":
3873
+ st.success(f"Último teste: {saved_test['message']}")
3874
+ else:
3875
+ st.error(f"Último teste: {saved_test['message']}")
3876
+
3877
+
3878
+ def render_llm_provider_cards(settings: dict[str, Any]) -> None:
3879
+ """Renderizar cartões LLM fora da form global para permitir acções por cartão."""
3880
+ migrated, changed = ensure_llm_provider_cards(settings)
3881
+ cards = [dict(item) for item in migrated.get(LLM_CARDS_KEY, [])]
3882
+ if changed:
3883
+ settings.update(migrated)
3884
+ write_json("settings.json", settings)
3885
+ with st.expander("LLM — providers e modelos", expanded=False):
3886
+ st.caption("Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo.")
3887
+ for index in range(len(cards)):
3888
+ _render_llm_card(settings, cards, index)
3889
+ st.divider()
3890
+ st.markdown("**Adicionar provider LLM**")
3891
+ provider_codes = [item.code for item in LLM_PROVIDER_CATALOG]
3892
+ provider_to_add = st.selectbox(
3893
+ "Provider LLM",
3894
+ provider_codes,
3895
+ format_func=lambda value: ui_text(provider_definition(value).label, current_ui_language()),
3896
+ key="llm_new_provider_choice",
3897
+ )
3898
+ if st.button("Configurar Novo Provedor LLM", type="primary", use_container_width=True, key="add_llm_provider_card"):
3899
+ new_card = new_llm_card(provider_to_add, card_id=f"llm-{provider_to_add}-{uuid.uuid4().hex[:8]}")
3900
+ cards.append(new_card)
3901
+ _persist_llm_cards(settings, cards, str(settings.get(LLM_ACTIVE_CARD_KEY) or DEFAULT_LLM_CARD_ID))
3902
+ st.rerun()
3903
+
3904
+
3700
3905
  def render_settings():
3701
3906
  st.title("Configuração API")
3702
3907
  st.caption("Configuração das APIs, providers, serviços e ferramentas técnicas usados pelo Thunderbolt. As credenciais ficam no storage local e não são enviadas para o GitHub.")
@@ -3729,6 +3934,7 @@ def render_settings():
3729
3934
  kaggle_api_key = text_setting("Kaggle API Key", "kaggle_api_key", secret=True, help_text="Chave criada em Kaggle > Settings > API. Nunca é incluída no notebook ou no GitHub.")
3730
3935
  with kaggle_cols[2]:
3731
3936
  kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
3937
+ _render_credential_status(kaggle_api_key)
3732
3938
 
3733
3939
  with st.expander("Niche Finder — execução através da Apify", expanded=False):
3734
3940
  st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
@@ -3741,6 +3947,7 @@ def render_settings():
3741
3947
  apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
3742
3948
  with apify_cols[3]:
3743
3949
  apify_run_timeout = st.number_input("Limite da execução (s)", min_value=30, max_value=7200, value=int(settings.get("apify_run_timeout_seconds", 900)), step=30)
3950
+ _render_credential_status(apify_api_token)
3744
3951
 
3745
3952
  with st.expander("Nano Banana — geração de thumbnails", expanded=False):
3746
3953
  st.caption("A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.")
@@ -3751,94 +3958,49 @@ def render_settings():
3751
3958
  with nano_cols[1]:
3752
3959
  gemini_image_aspect_ratio = st.selectbox("Proporção da thumbnail", ["16:9", "9:16", "1:1", "4:5"], index=["16:9", "9:16", "1:1", "4:5"].index(str(settings.get("gemini_image_aspect_ratio") or "16:9")) if str(settings.get("gemini_image_aspect_ratio") or "16:9") in {"16:9", "9:16", "1:1", "4:5"} else 0)
3753
3960
  gemini_image_size = st.selectbox("Tamanho da imagem", ["1K", "2K", "4K"], index=["1K", "2K", "4K"].index(str(settings.get("gemini_image_size") or "1K")) if str(settings.get("gemini_image_size") or "1K") in {"1K", "2K", "4K"} else 0)
3961
+ _render_credential_status(gemini_image_api_key)
3754
3962
 
3755
- with st.expander("LLM — providers e modelos", expanded=False):
3756
- provider_options = ["openai", "moonshot", "shengsuanyun", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
3757
- current_llm_provider = str(settings.get("llm_provider") or DEFAULT_LLM_PROVIDER).strip().lower()
3758
- llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(current_llm_provider) if current_llm_provider in provider_options else 0)
3759
- st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
3760
- st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
3761
- openai_cols = st.columns(3)
3762
- with openai_cols[0]:
3763
- openai_api_key = text_setting("OpenAI/ NVIDIA NIM API key", "openai_api_key", secret=True, help_text="API key do OpenAI ou do NVIDIA Build/NIM. A credencial fica apenas no storage local.")
3764
- with openai_cols[1]:
3765
- openai_base_url = st.text_input("OpenAI/ NVIDIA NIM Base URL", value=str(settings.get("openai_base_url", "") or DEFAULT_NVIDIA_NIM_BASE_URL), help="Ex.: https://integrate.api.nvidia.com/v1. O Thunderbolt acrescenta /models para descobrir os modelos.", key="settings_openai_base_url")
3766
- with openai_cols[2]:
3767
- cached_catalog = st.session_state.get("openai_model_catalog", {})
3768
- catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
3769
- cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
3770
- current_model_name = str(settings.get("openai_model_name", "") or "")
3771
- manual_option = "__manual_model__"
3772
- if cached_models:
3773
- model_options = [manual_option, *cached_models]
3774
- model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
3775
- selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
3776
- if selected_model == manual_option:
3777
- openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
3778
- else:
3779
- openai_model_name = selected_model
3780
- else:
3781
- openai_model_name = st.text_input("Modelo OpenAI/ NVIDIA NIM", value=current_model_name, help="Pode escrever um ID manualmente se o endpoint não disponibilizar /models.", key="settings_openai_model_name")
3782
- refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
3783
- if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
3784
- st.warning(str(cached_catalog["error"]))
3785
- elif cached_models:
3786
- st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
3787
- else:
3788
- st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
3789
- llm_fields = [
3790
- ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
3791
- ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
3792
- ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
3793
- ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
3794
- ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
3795
- ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
3796
- ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
3797
- ]
3798
- for label, prefix, has_key in llm_fields:
3799
- st.markdown(f"**{label}**")
3800
- cols = st.columns(3)
3801
- with cols[0]:
3802
- if has_key:
3803
- settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
3804
- else:
3805
- settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
3806
- with cols[1]:
3807
- settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
3808
- with cols[2]:
3809
- settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
3810
3963
 
3811
3964
  with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=False):
3812
3965
  cols = st.columns(2)
3813
3966
  with cols[0]:
3814
3967
  azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
3968
+ _render_credential_status(azure_speech_key)
3815
3969
  azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
3816
3970
  siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
3971
+ _render_credential_status(siliconflow_tts_api_key)
3817
3972
  minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
3973
+ _render_credential_status(minimax_tts_api_key)
3818
3974
  minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
3819
3975
  minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
3820
3976
  minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
3821
3977
  with cols[1]:
3822
3978
  elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
3979
+ _render_credential_status(elevenlabs_api_key)
3823
3980
  elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
3824
3981
  chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
3825
3982
  chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
3983
+ _render_credential_status(chatterbox_api_key, local=True, required=False)
3826
3984
  chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
3827
3985
  sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
3986
+ _render_credential_status(sonilo_api_key)
3828
3987
  sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
3829
3988
  st.markdown("**Suno — agente musical opcional**")
3830
3989
  suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
3990
+ _render_credential_status(suno_api_key)
3831
3991
  suno_api_base_url = text_setting("Suno API Base URL", "suno_api_base_url", help_text="Use o endpoint compatível fornecido pelo seu acesso Suno; não é inventado pelo Thunderbolt.")
3832
3992
  suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
3833
3993
 
3834
3994
  with st.expander("TikTok for Developers — Client ID e Client Secret", expanded=False):
3835
3995
  st.caption("Apenas as credenciais da aplicação ficam nesta UI. Redirect URI, scopes, autorização e tokens são geridos no TikTok for Developers Playground.")
3836
3996
  tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
3997
+ _render_credential_status(tiktok_client_key)
3837
3998
  tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
3838
3999
 
3839
4000
  with st.expander("Publicação através do Upload-Post", expanded=False):
3840
4001
  upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
3841
4002
  upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
4003
+ _render_credential_status(upload_post_api_key)
3842
4004
  upload_post_username = text_setting("Upload-Post username", "upload_post_username")
3843
4005
  upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
3844
4006
  upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
@@ -3850,6 +4012,7 @@ def render_settings():
3850
4012
  postiz_cols = st.columns(2)
3851
4013
  with postiz_cols[0]:
3852
4014
  postiz_api_key = text_setting("Postiz API key", "postiz_api_key", secret=True, help_text="API key criada nas definições do Postiz. A API HTTP usa o valor bruto no cabeçalho Authorization.")
4015
+ _render_credential_status(postiz_api_key)
3853
4016
  postiz_base_url = text_setting("Postiz Public API Base URL", "postiz_base_url", help_text="Cloud: https://api.postiz.com/public/v1 · Self-hosted: https://seu-servidor/api/public/v1")
3854
4017
  postiz_integration_id = text_setting("Postiz integração padrão", "postiz_integration_id", help_text="ID do canal/integração devolvido por GET /integrations.")
3855
4018
  with postiz_cols[1]:
@@ -3857,23 +4020,12 @@ def render_settings():
3857
4020
  postiz_auto_publish = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)))
3858
4021
  st.caption("No Upload, a aba Postiz permite carregar as integrações e enviar vídeos manualmente. No fluxo recomendado, Postiz só é tentado depois da API Oficial e do Upload directo.")
3859
4022
 
3860
- if refresh_openai_models:
3861
- try:
3862
- discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
3863
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
3864
- st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
3865
- st.rerun()
3866
- except ModelDiscoveryError as exc:
3867
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
3868
- st.rerun()
3869
-
3870
4023
  save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
3871
4024
  if save_all_settings:
3872
4025
  settings.update({
3873
4026
  "port": port, "moneyprinter_path": moneyprinter_path,
3874
4027
  "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
3875
4028
  "apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
3876
- "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
3877
4029
  "gemini_image_api_key": gemini_image_api_key, "gemini_image_model": gemini_image_model, "gemini_image_aspect_ratio": gemini_image_aspect_ratio, "gemini_image_size": gemini_image_size,
3878
4030
  "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
3879
4031
  "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
@@ -3898,6 +4050,7 @@ def render_settings():
3898
4050
  st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
3899
4051
  except Exception as exc:
3900
4052
  st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
4053
+ render_llm_provider_cards(settings)
3901
4054
  with material_sources_tab:
3902
4055
  render_material_source_api_keys(settings)
3903
4056
 
@@ -8,6 +8,8 @@ from typing import Any
8
8
 
9
9
  import requests
10
10
 
11
+ from .llm_providers import active_llm_card, provider_definition
12
+
11
13
 
12
14
  ROOT = Path(__file__).resolve().parents[1]
13
15
  REFERENCES_DIR = ROOT / "seed" / "references"
@@ -28,14 +30,12 @@ class CreativeGenerationError(RuntimeError):
28
30
 
29
31
 
30
32
  def _provider_config(settings: dict[str, Any]) -> tuple[str, str, str, str]:
31
- provider = str(settings.get("llm_provider") or "openai").strip().lower()
32
- key = str(settings.get(f"{provider}_api_key") or "").strip()
33
- base_url = str(settings.get(f"{provider}_base_url") or "").strip()
34
- model = str(settings.get(f"{provider}_model_name") or "").strip()
35
- if provider == "openai":
36
- base_url = base_url or "https://api.openai.com/v1"
37
- elif provider == "ollama":
38
- base_url = base_url or "http://127.0.0.1:11434/v1"
33
+ card = active_llm_card(settings)
34
+ provider = str(card.get("provider") or "openai").strip().lower()
35
+ definition = provider_definition(provider)
36
+ key = str(card.get("api_key") or "").strip()
37
+ base_url = str(card.get("base_url") or "").strip() or definition.default_base_url
38
+ model = str(card.get("model") or "").strip()
39
39
  if not base_url:
40
40
  raise CreativeGenerationError(
41
41
  f"O provider LLM '{provider}' não tem Base URL configurada em Configuração API > API Keys > Serviços e modelos."
@@ -44,7 +44,7 @@ def _provider_config(settings: dict[str, Any]) -> tuple[str, str, str, str]:
44
44
  raise CreativeGenerationError(
45
45
  f"O provider LLM '{provider}' não tem modelo configurado em Configuração API > API Keys > Serviços e modelos."
46
46
  )
47
- if provider not in {"ollama", "litellm"} and not key:
47
+ if definition.requires_api_key and not key:
48
48
  raise CreativeGenerationError(
49
49
  f"Configure a API key do provider LLM '{provider}' em Configuração API > API Keys > Serviços e modelos."
50
50
  )
@@ -141,6 +141,73 @@ UI_TRANSLATIONS: dict[str, dict[str, str]] = {
141
141
  }
142
142
 
143
143
 
144
+ # Labels for the independent LLM provider cards.
145
+ _LLM_TRANSLATIONS: dict[str, dict[str, str]] = {
146
+ "pt": {
147
+ "LLM — providers e modelos": "LLM — providers e modelos", "Adicionar provider LLM": "Adicionar provider LLM", "Provider LLM": "Provider LLM", "Configurar Novo Provedor LLM": "Configurar Novo Provedor LLM", "API key": "API key", "Modelo": "Modelo", "Base URL": "Base URL", "Modelo manual": "Modelo manual", "Escrever modelo manualmente": "Escrever modelo manualmente", "Provider activo": "Provider activo", "LLM Telegram": "LLM Telegram", "Testar chamada API": "Testar chamada API", "Consultar modelos": "Consultar modelos", "Salvar": "Salvar", "Remover cartão": "Remover cartão", "Último teste:": "Último teste:", "Teste API:": "Teste API:", "Missing key": "Missing key", "Missing configuration": "Missing configuration", "Configured": "Configurado", "Local / sem API key": "Local / sem API key", "Este provider não exige API key.": "Este provider não exige API key.", "OpenAI / NVIDIA NIM": "OpenAI / NVIDIA NIM", "OpenRouter": "OpenRouter", "Moonshot / Kimi": "Moonshot / Kimi", "Shengsuan Cloud": "Shengsuan Cloud", "Google Gemini": "Google Gemini", "DeepSeek": "DeepSeek", "Alibaba Qwen / DashScope": "Alibaba Qwen / DashScope", "Mistral": "Mistral", "Groq": "Groq", "xAI Grok": "xAI Grok", "MiniMax": "MiniMax", "Xiaomi MiMo": "Xiaomi MiMo", "Cloudflare Workers AI": "Cloudflare Workers AI", "ModelScope": "ModelScope", "AIHubMix": "AIHubMix", "AIML API": "AIML API", "EvoLink": "EvoLink", "Ollama": "Ollama", "LM Studio": "LM Studio", "llama.cpp": "llama.cpp", "OneAPI": "OneAPI", "LiteLLM Proxy": "Proxy LiteLLM", "Pollinations AI": "Pollinations AI", "Together AI": "Together AI", "Fireworks AI": "Fireworks AI", "SiliconFlow": "SiliconFlow", "Cohere": "Cohere", "Hugging Face": "Hugging Face", "GitHub Models": "GitHub Models", "Cerebras": "Cerebras", "SambaNova": "SambaNova", "Perplexity": "Perplexity", "OpenAI-compatible personalizado": "OpenAI-compatible personalizado",
148
+ },
149
+ "en": {
150
+ "LLM — providers e modelos": "LLM — providers and models", "Adicionar provider LLM": "Add LLM provider", "Provider LLM": "LLM provider", "Configurar Novo Provedor LLM": "Configure New LLM Provider", "API key": "API key", "Modelo": "Model", "Base URL": "Base URL", "Modelo manual": "Manual model", "Escrever modelo manualmente": "Write model manually", "Provider activo": "Provider enabled", "LLM Telegram": "LLM Telegram", "Testar chamada API": "Test API call", "Consultar modelos": "Fetch models", "Salvar": "Save", "Remover cartão": "Remove card", "Último teste:": "Last test:", "Teste API:": "API test:", "Missing key": "Missing key", "Missing configuration": "Missing configuration", "Configured": "Configured", "Local / sem API key": "Local / no API key", "Este provider não exige API key.": "This provider does not require an API key.", "OpenAI / NVIDIA NIM": "OpenAI / NVIDIA NIM", "OpenRouter": "OpenRouter", "Moonshot / Kimi": "Moonshot / Kimi", "Shengsuan Cloud": "Shengsuan Cloud", "Google Gemini": "Google Gemini", "DeepSeek": "DeepSeek", "Alibaba Qwen / DashScope": "Alibaba Qwen / DashScope", "Mistral": "Mistral", "Groq": "Groq", "xAI Grok": "xAI Grok", "MiniMax": "MiniMax", "Xiaomi MiMo": "Xiaomi MiMo", "Cloudflare Workers AI": "Cloudflare Workers AI", "ModelScope": "ModelScope", "AIHubMix": "AIHubMix", "AIML API": "AIML API", "EvoLink": "EvoLink", "Ollama": "Ollama", "LM Studio": "LM Studio", "llama.cpp": "llama.cpp", "OneAPI": "OneAPI", "LiteLLM Proxy": "LiteLLM Proxy", "Pollinations AI": "Pollinations AI", "Together AI": "Together AI", "Fireworks AI": "Fireworks AI", "SiliconFlow": "SiliconFlow", "Cohere": "Cohere", "Hugging Face": "Hugging Face", "GitHub Models": "GitHub Models", "Cerebras": "Cerebras", "SambaNova": "SambaNova", "Perplexity": "Perplexity", "OpenAI-compatible personalizado": "Custom OpenAI-compatible",
151
+ },
152
+ "zh": {
153
+ "LLM — providers e modelos": "LLM — 提供商和模型", "Adicionar provider LLM": "添加 LLM 提供商", "Provider LLM": "LLM 提供商", "Configurar Novo Provedor LLM": "配置新的 LLM 提供商", "API key": "API 密钥", "Modelo": "模型", "Base URL": "基础 URL", "Modelo manual": "手动模型", "Escrever modelo manualmente": "手动输入模型", "Provider activo": "启用提供商", "LLM Telegram": "Telegram LLM", "Testar chamada API": "测试 API 调用", "Consultar modelos": "获取模型", "Salvar": "保存", "Remover cartão": "删除卡片", "Último teste:": "上次测试:", "Teste API:": "API 测试:", "Missing key": "缺少密钥", "Missing configuration": "缺少配置", "Configured": "已配置", "Local / sem API key": "本地 / 无需 API 密钥", "Este provider não exige API key.": "此提供商不需要 API 密钥。", "OpenAI / NVIDIA NIM": "OpenAI / NVIDIA NIM", "OpenRouter": "OpenRouter", "Moonshot / Kimi": "Moonshot / Kimi", "Shengsuan Cloud": "Shengsuan Cloud", "Google Gemini": "Google Gemini", "DeepSeek": "DeepSeek", "Alibaba Qwen / DashScope": "阿里 Qwen / DashScope", "Mistral": "Mistral", "Groq": "Groq", "xAI Grok": "xAI Grok", "MiniMax": "MiniMax", "Xiaomi MiMo": "小米 MiMo", "Cloudflare Workers AI": "Cloudflare Workers AI", "ModelScope": "ModelScope", "AIHubMix": "AIHubMix", "AIML API": "AIML API", "EvoLink": "EvoLink", "Ollama": "Ollama", "LM Studio": "LM Studio", "llama.cpp": "llama.cpp", "OneAPI": "OneAPI", "LiteLLM Proxy": "LiteLLM 代理", "Pollinations AI": "Pollinations AI", "Together AI": "Together AI", "Fireworks AI": "Fireworks AI", "SiliconFlow": "SiliconFlow", "Cohere": "Cohere", "Hugging Face": "Hugging Face", "GitHub Models": "GitHub Models", "Cerebras": "Cerebras", "SambaNova": "SambaNova", "Perplexity": "Perplexity", "OpenAI-compatible personalizado": "自定义 OpenAI 兼容",
154
+ },
155
+ "de": {
156
+ "LLM — providers e modelos": "LLM — Anbieter und Modelle", "Adicionar provider LLM": "LLM-Anbieter hinzufügen", "Provider LLM": "LLM-Anbieter", "Configurar Novo Provedor LLM": "Neuen LLM-Anbieter konfigurieren", "API key": "API-Schlüssel", "Modelo": "Modell", "Base URL": "Basis-URL", "Modelo manual": "Manuelles Modell", "Escrever modelo manualmente": "Modell manuell eingeben", "Provider activo": "Anbieter aktiv", "LLM Telegram": "LLM Telegram", "Testar chamada API": "API-Aufruf testen", "Consultar modelos": "Modelle abrufen", "Salvar": "Speichern", "Remover cartão": "Karte entfernen", "Último teste:": "Letzter Test:", "Teste API:": "API-Test:", "Missing key": "Schlüssel fehlt", "Missing configuration": "Konfiguration fehlt", "Configured": "Konfiguriert", "Local / sem API key": "Lokal / kein API-Schlüssel", "Este provider não exige API key.": "Dieser Anbieter benötigt keinen API-Schlüssel.", "OpenAI / NVIDIA NIM": "OpenAI / NVIDIA NIM", "OpenRouter": "OpenRouter", "Moonshot / Kimi": "Moonshot / Kimi", "Shengsuan Cloud": "Shengsuan Cloud", "Google Gemini": "Google Gemini", "DeepSeek": "DeepSeek", "Alibaba Qwen / DashScope": "Alibaba Qwen / DashScope", "Mistral": "Mistral", "Groq": "Groq", "xAI Grok": "xAI Grok", "MiniMax": "MiniMax", "Xiaomi MiMo": "Xiaomi MiMo", "Cloudflare Workers AI": "Cloudflare Workers AI", "ModelScope": "ModelScope", "AIHubMix": "AIHubMix", "AIML API": "AIML API", "EvoLink": "EvoLink", "Ollama": "Ollama", "LM Studio": "LM Studio", "llama.cpp": "llama.cpp", "OneAPI": "OneAPI", "LiteLLM Proxy": "LiteLLM-Proxy", "Pollinations AI": "Pollinations AI", "Together AI": "Together AI", "Fireworks AI": "Fireworks AI", "SiliconFlow": "SiliconFlow", "Cohere": "Cohere", "Hugging Face": "Hugging Face", "GitHub Models": "GitHub-Modelle", "Cerebras": "Cerebras", "SambaNova": "SambaNova", "Perplexity": "Perplexity", "OpenAI-compatible personalizado": "Benutzerdefiniertes OpenAI-kompatibles",
157
+ },
158
+ "vi": {
159
+ "LLM — providers e modelos": "LLM — nhà cung cấp và mô hình", "Adicionar provider LLM": "Thêm nhà cung cấp LLM", "Provider LLM": "Nhà cung cấp LLM", "Configurar Novo Provedor LLM": "Định cấu hình nhà cung cấp LLM mới", "API key": "Khóa API", "Modelo": "Mô hình", "Base URL": "URL cơ sở", "Modelo manual": "Mô hình thủ công", "Escrever modelo manualmente": "Nhập mô hình thủ công", "Provider activo": "Bật nhà cung cấp", "LLM Telegram": "LLM Telegram", "Testar chamada API": "Kiểm tra lệnh gọi API", "Consultar modelos": "Lấy danh sách mô hình", "Salvar": "Lưu", "Remover cartão": "Xóa thẻ", "Último teste:": "Lần kiểm tra cuối:", "Teste API:": "Kiểm tra API:", "Missing key": "Thiếu khóa", "Missing configuration": "Thiếu cấu hình", "Configured": "Đã cấu hình", "Local / sem API key": "Cục bộ / không cần khóa API", "Este provider não exige API key.": "Nhà cung cấp này không yêu cầu khóa API.", "OpenAI / NVIDIA NIM": "OpenAI / NVIDIA NIM", "OpenRouter": "OpenRouter", "Moonshot / Kimi": "Moonshot / Kimi", "Shengsuan Cloud": "Shengsuan Cloud", "Google Gemini": "Google Gemini", "DeepSeek": "DeepSeek", "Alibaba Qwen / DashScope": "Alibaba Qwen / DashScope", "Mistral": "Mistral", "Groq": "Groq", "xAI Grok": "xAI Grok", "MiniMax": "MiniMax", "Xiaomi MiMo": "Xiaomi MiMo", "Cloudflare Workers AI": "Cloudflare Workers AI", "ModelScope": "ModelScope", "AIHubMix": "AIHubMix", "AIML API": "AIML API", "EvoLink": "EvoLink", "Ollama": "Ollama", "LM Studio": "LM Studio", "llama.cpp": "llama.cpp", "OneAPI": "OneAPI", "LiteLLM Proxy": "Proxy LiteLLM", "Pollinations AI": "Pollinations AI", "Together AI": "Together AI", "Fireworks AI": "Fireworks AI", "SiliconFlow": "SiliconFlow", "Cohere": "Cohere", "Hugging Face": "Hugging Face", "GitHub Models": "GitHub Models", "Cerebras": "Cerebras", "SambaNova": "SambaNova", "Perplexity": "Perplexity", "OpenAI-compatible personalizado": "OpenAI-compatible tùy chỉnh",
160
+ },
161
+ "tr": {
162
+ "LLM — providers e modelos": "LLM — sağlayıcılar ve modeller", "Adicionar provider LLM": "LLM sağlayıcısı ekle", "Provider LLM": "LLM sağlayıcısı", "Configurar Novo Provedor LLM": "Yeni LLM sağlayıcısını yapılandır", "API key": "API anahtarı", "Modelo": "Model", "Base URL": "Temel URL", "Modelo manual": "Manuel model", "Escrever modelo manualmente": "Modeli manuel yaz", "Provider activo": "Sağlayıcı etkin", "LLM Telegram": "LLM Telegram", "Testar chamada API": "API çağrısını test et", "Consultar modelos": "Modelleri getir", "Salvar": "Kaydet", "Remover cartão": "Kartı kaldır", "Último teste:": "Son test:", "Teste API:": "API testi:", "Missing key": "Anahtar eksik", "Missing configuration": "Yapılandırma eksik", "Configured": "Yapılandırıldı", "Local / sem API key": "Yerel / API anahtarı gerekmez", "Este provider não exige API key.": "Bu sağlayıcı API anahtarı gerektirmez.", "OpenAI / NVIDIA NIM": "OpenAI / NVIDIA NIM", "OpenRouter": "OpenRouter", "Moonshot / Kimi": "Moonshot / Kimi", "Shengsuan Cloud": "Shengsuan Cloud", "Google Gemini": "Google Gemini", "DeepSeek": "DeepSeek", "Alibaba Qwen / DashScope": "Alibaba Qwen / DashScope", "Mistral": "Mistral", "Groq": "Groq", "xAI Grok": "xAI Grok", "MiniMax": "MiniMax", "Xiaomi MiMo": "Xiaomi MiMo", "Cloudflare Workers AI": "Cloudflare Workers AI", "ModelScope": "ModelScope", "AIHubMix": "AIHubMix", "AIML API": "AIML API", "EvoLink": "EvoLink", "Ollama": "Ollama", "LM Studio": "LM Studio", "llama.cpp": "llama.cpp", "OneAPI": "OneAPI", "LiteLLM Proxy": "LiteLLM Proxy", "Pollinations AI": "Pollinations AI", "Together AI": "Together AI", "Fireworks AI": "Fireworks AI", "SiliconFlow": "SiliconFlow", "Cohere": "Cohere", "Hugging Face": "Hugging Face", "GitHub Models": "GitHub Models", "Cerebras": "Cerebras", "SambaNova": "SambaNova", "Perplexity": "Perplexity", "OpenAI-compatible personalizado": "Özel OpenAI uyumlu",
163
+ },
164
+ "ru": {
165
+ "LLM — providers e modelos": "LLM — провайдеры и модели", "Adicionar provider LLM": "Добавить провайдер LLM", "Provider LLM": "Провайдер LLM", "Configurar Novo Provedor LLM": "Настроить нового провайдера LLM", "API key": "API-ключ", "Modelo": "Модель", "Base URL": "Базовый URL", "Modelo manual": "Модель вручную", "Escrever modelo manualmente": "Ввести модель вручную", "Provider activo": "Провайдер активен", "LLM Telegram": "LLM Telegram", "Testar chamada API": "Проверить вызов API", "Consultar modelos": "Получить модели", "Salvar": "Сохранить", "Remover cartão": "Удалить карточку", "Último teste:": "Последняя проверка:", "Teste API:": "Проверка API:", "Missing key": "Нет ключа", "Missing configuration": "Нет конфигурации", "Configured": "Настроено", "Local / sem API key": "Локальный / ключ API не нужен", "Este provider não exige API key.": "Этому провайдеру не нужен API-ключ.", "OpenAI / NVIDIA NIM": "OpenAI / NVIDIA NIM", "OpenRouter": "OpenRouter", "Moonshot / Kimi": "Moonshot / Kimi", "Shengsuan Cloud": "Shengsuan Cloud", "Google Gemini": "Google Gemini", "DeepSeek": "DeepSeek", "Alibaba Qwen / DashScope": "Alibaba Qwen / DashScope", "Mistral": "Mistral", "Groq": "Groq", "xAI Grok": "xAI Grok", "MiniMax": "MiniMax", "Xiaomi MiMo": "Xiaomi MiMo", "Cloudflare Workers AI": "Cloudflare Workers AI", "ModelScope": "ModelScope", "AIHubMix": "AIHubMix", "AIML API": "AIML API", "EvoLink": "EvoLink", "Ollama": "Ollama", "LM Studio": "LM Studio", "llama.cpp": "llama.cpp", "OneAPI": "OneAPI", "LiteLLM Proxy": "Прокси LiteLLM", "Pollinations AI": "Pollinations AI", "Together AI": "Together AI", "Fireworks AI": "Fireworks AI", "SiliconFlow": "SiliconFlow", "Cohere": "Cohere", "Hugging Face": "Hugging Face", "GitHub Models": "Модели GitHub", "Cerebras": "Cerebras", "SambaNova": "SambaNova", "Perplexity": "Perplexity", "OpenAI-compatible personalizado": "Пользовательский OpenAI-совместимый",
166
+ },
167
+ "es": {
168
+ "LLM — providers e modelos": "LLM — proveedores y modelos", "Adicionar provider LLM": "Añadir proveedor LLM", "Provider LLM": "Proveedor LLM", "Configurar Novo Provedor LLM": "Configurar nuevo proveedor LLM", "API key": "Clave API", "Modelo": "Modelo", "Base URL": "URL base", "Modelo manual": "Modelo manual", "Escrever modelo manualmente": "Escribir modelo manualmente", "Provider activo": "Proveedor activo", "LLM Telegram": "LLM Telegram", "Testar chamada API": "Probar llamada API", "Consultar modelos": "Consultar modelos", "Salvar": "Guardar", "Remover cartão": "Eliminar tarjeta", "Último teste:": "Última prueba:", "Teste API:": "Prueba API:", "Missing key": "Falta la clave", "Missing configuration": "Falta configuración", "Configured": "Configurado", "Local / sem API key": "Local / sin clave API", "Este provider não exige API key.": "Este proveedor no requiere clave API.", "OpenAI / NVIDIA NIM": "OpenAI / NVIDIA NIM", "OpenRouter": "OpenRouter", "Moonshot / Kimi": "Moonshot / Kimi", "Shengsuan Cloud": "Shengsuan Cloud", "Google Gemini": "Google Gemini", "DeepSeek": "DeepSeek", "Alibaba Qwen / DashScope": "Alibaba Qwen / DashScope", "Mistral": "Mistral", "Groq": "Groq", "xAI Grok": "xAI Grok", "MiniMax": "MiniMax", "Xiaomi MiMo": "Xiaomi MiMo", "Cloudflare Workers AI": "Cloudflare Workers AI", "ModelScope": "ModelScope", "AIHubMix": "AIHubMix", "AIML API": "AIML API", "EvoLink": "EvoLink", "Ollama": "Ollama", "LM Studio": "LM Studio", "llama.cpp": "llama.cpp", "OneAPI": "OneAPI", "LiteLLM Proxy": "Proxy LiteLLM", "Pollinations AI": "Pollinations AI", "Together AI": "Together AI", "Fireworks AI": "Fireworks AI", "SiliconFlow": "SiliconFlow", "Cohere": "Cohere", "Hugging Face": "Hugging Face", "GitHub Models": "Modelos de GitHub", "Cerebras": "Cerebras", "SambaNova": "SambaNova", "Perplexity": "Perplexity", "OpenAI-compatible personalizado": "OpenAI compatible personalizado",
169
+ },
170
+ "id": {
171
+ "LLM — providers e modelos": "LLM — penyedia dan model", "Adicionar provider LLM": "Tambah penyedia LLM", "Provider LLM": "Penyedia LLM", "Configurar Novo Provedor LLM": "Konfigurasikan Penyedia LLM Baru", "API key": "Kunci API", "Modelo": "Model", "Base URL": "URL dasar", "Modelo manual": "Model manual", "Escrever modelo manualmente": "Tulis model secara manual", "Provider activo": "Penyedia aktif", "LLM Telegram": "LLM Telegram", "Testar chamada API": "Uji panggilan API", "Consultar modelos": "Ambil model", "Salvar": "Simpan", "Remover cartão": "Hapus kartu", "Último teste:": "Tes terakhir:", "Teste API:": "Tes API:", "Missing key": "Kunci tidak ada", "Missing configuration": "Konfigurasi tidak ada", "Configured": "Dikonfigurasi", "Local / sem API key": "Lokal / tanpa kunci API", "Este provider não exige API key.": "Penyedia ini tidak memerlukan kunci API.", "OpenAI / NVIDIA NIM": "OpenAI / NVIDIA NIM", "OpenRouter": "OpenRouter", "Moonshot / Kimi": "Moonshot / Kimi", "Shengsuan Cloud": "Shengsuan Cloud", "Google Gemini": "Google Gemini", "DeepSeek": "DeepSeek", "Alibaba Qwen / DashScope": "Alibaba Qwen / DashScope", "Mistral": "Mistral", "Groq": "Groq", "xAI Grok": "xAI Grok", "MiniMax": "MiniMax", "Xiaomi MiMo": "Xiaomi MiMo", "Cloudflare Workers AI": "Cloudflare Workers AI", "ModelScope": "ModelScope", "AIHubMix": "AIHubMix", "AIML API": "AIML API", "EvoLink": "EvoLink", "Ollama": "Ollama", "LM Studio": "LM Studio", "llama.cpp": "llama.cpp", "OneAPI": "OneAPI", "LiteLLM Proxy": "Proxy LiteLLM", "Pollinations AI": "Pollinations AI", "Together AI": "Together AI", "Fireworks AI": "Fireworks AI", "SiliconFlow": "SiliconFlow", "Cohere": "Cohere", "Hugging Face": "Hugging Face", "GitHub Models": "Model GitHub", "Cerebras": "Cerebras", "SambaNova": "SambaNova", "Perplexity": "Perplexity", "OpenAI-compatible personalizado": "OpenAI-compatible khusus",
172
+ },
173
+ "it": {
174
+ "LLM — providers e modelos": "LLM — provider e modelli", "Adicionar provider LLM": "Aggiungi provider LLM", "Provider LLM": "Provider LLM", "Configurar Novo Provedor LLM": "Configura nuovo provider LLM", "API key": "Chiave API", "Modelo": "Modello", "Base URL": "URL base", "Modelo manual": "Modello manuale", "Escrever modelo manualmente": "Inserisci modello manualmente", "Provider activo": "Provider attivo", "LLM Telegram": "LLM Telegram", "Testar chamada API": "Testa chiamata API", "Consultar modelos": "Recupera modelli", "Salvar": "Salva", "Remover cartão": "Rimuovi scheda", "Último teste:": "Ultimo test:", "Teste API:": "Test API:", "Missing key": "Chiave mancante", "Missing configuration": "Configurazione mancante", "Configured": "Configurato", "Local / sem API key": "Locale / nessuna chiave API", "Este provider não exige API key.": "Questo provider non richiede una chiave API.", "OpenAI / NVIDIA NIM": "OpenAI / NVIDIA NIM", "OpenRouter": "OpenRouter", "Moonshot / Kimi": "Moonshot / Kimi", "Shengsuan Cloud": "Shengsuan Cloud", "Google Gemini": "Google Gemini", "DeepSeek": "DeepSeek", "Alibaba Qwen / DashScope": "Alibaba Qwen / DashScope", "Mistral": "Mistral", "Groq": "Groq", "xAI Grok": "xAI Grok", "MiniMax": "MiniMax", "Xiaomi MiMo": "Xiaomi MiMo", "Cloudflare Workers AI": "Cloudflare Workers AI", "ModelScope": "ModelScope", "AIHubMix": "AIHubMix", "AIML API": "AIML API", "EvoLink": "EvoLink", "Ollama": "Ollama", "LM Studio": "LM Studio", "llama.cpp": "llama.cpp", "OneAPI": "OneAPI", "LiteLLM Proxy": "Proxy LiteLLM", "Pollinations AI": "Pollinations AI", "Together AI": "Together AI", "Fireworks AI": "Fireworks AI", "SiliconFlow": "SiliconFlow", "Cohere": "Cohere", "Hugging Face": "Hugging Face", "GitHub Models": "Modelli GitHub", "Cerebras": "Cerebras", "SambaNova": "SambaNova", "Perplexity": "Perplexity", "OpenAI-compatible personalizado": "OpenAI compatibile personalizzato",
175
+ },
176
+ }
177
+ for _language_code, _llm_values in _LLM_TRANSLATIONS.items():
178
+ UI_TRANSLATIONS[_language_code].update(_llm_values)
179
+
180
+ _LLM_EXTRA_PROVIDER_TRANSLATIONS = {
181
+ "pt": {"Azure OpenAI": "Azure OpenAI", "VolcEngine Ark": "VolcEngine Ark"},
182
+ "en": {"Azure OpenAI": "Azure OpenAI", "VolcEngine Ark": "VolcEngine Ark"},
183
+ "zh": {"Azure OpenAI": "Azure OpenAI", "VolcEngine Ark": "VolcEngine Ark"},
184
+ "de": {"Azure OpenAI": "Azure OpenAI", "VolcEngine Ark": "VolcEngine Ark"},
185
+ "vi": {"Azure OpenAI": "Azure OpenAI", "VolcEngine Ark": "VolcEngine Ark"},
186
+ "tr": {"Azure OpenAI": "Azure OpenAI", "VolcEngine Ark": "VolcEngine Ark"},
187
+ "ru": {"Azure OpenAI": "Azure OpenAI", "VolcEngine Ark": "VolcEngine Ark"},
188
+ "es": {"Azure OpenAI": "Azure OpenAI", "VolcEngine Ark": "VolcEngine Ark"},
189
+ "id": {"Azure OpenAI": "Azure OpenAI", "VolcEngine Ark": "VolcEngine Ark"},
190
+ "it": {"Azure OpenAI": "Azure OpenAI", "VolcEngine Ark": "VolcEngine Ark"},
191
+ }
192
+ for _language_code, _llm_extra_values in _LLM_EXTRA_PROVIDER_TRANSLATIONS.items():
193
+ UI_TRANSLATIONS[_language_code].update(_llm_extra_values)
194
+
195
+ _LLM_CONTROL_TRANSLATIONS = {
196
+ "pt": {"LLM principal": "LLM principal", "Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.": "Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.", "Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.": "Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.", "Account Id": "Account ID", "Gateway Id": "Gateway ID", "Api Version": "Versão da API", "Endpoint gerido pelo provider: ": "Endpoint gerido pelo provider: ", "Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.": "Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.", "ID do modelo usado pelo endpoint Chat Completions.": "ID do modelo usado pelo endpoint Chat Completions.", "Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo.": "Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo."},
197
+ "en": {"LLM principal": "Primary LLM", "Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.": "Use this card as the primary provider for topics, scripts, titles and keywords.", "Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.": "Use this card exclusively for Telegram notification routing. Only one card can be selected.", "Account Id": "Account ID", "Gateway Id": "Gateway ID", "Api Version": "API version", "Endpoint gerido pelo provider: ": "Provider-managed endpoint: ", "Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.": "This field appears only for providers with a configurable OpenAI-compatible endpoint.", "ID do modelo usado pelo endpoint Chat Completions.": "Model ID used by the Chat Completions endpoint.", "Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo.": "Configure each provider in an independent card. You can repeat a provider for multiple API keys; the active card is used for content generation."},
198
+ "zh": {"LLM principal": "主要 LLM", "Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.": "将此卡片用作主题、脚本、标题和关键词的主要提供商。", "Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.": "此卡片仅用于 Telegram 通知路由。只能选择一个卡片。", "Account Id": "账户 ID", "Gateway Id": "网关 ID", "Api Version": "API 版本", "Endpoint gerido pelo provider: ": "提供商管理的端点:", "Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.": "仅在提供商支持自定义 OpenAI 兼容端点时显示。", "ID do modelo usado pelo endpoint Chat Completions.": "Chat Completions 端点使用的模型 ID。", "Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo.": "在独立卡片中配置每个提供商。可为同一提供商添加多个 API 密钥;内容生成使用活动卡片。"},
199
+ "de": {"LLM principal": "Primärer LLM", "Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.": "Diese Karte als primären Anbieter für Themen, Skripte, Titel und Keywords verwenden.", "Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.": "Diese Karte ausschließlich für die Telegram-Benachrichtigungsweiterleitung verwenden. Nur eine Karte kann ausgewählt werden.", "Account Id": "Konto-ID", "Gateway Id": "Gateway-ID", "Api Version": "API-Version", "Endpoint gerido pelo provider: ": "Vom Anbieter verwalteter Endpunkt: ", "Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.": "Dieses Feld erscheint nur bei Anbietern mit konfigurierbarem OpenAI-kompatiblem Endpunkt.", "ID do modelo usado pelo endpoint Chat Completions.": "Vom Chat-Completions-Endpunkt verwendete Modell-ID.", "Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo.": "Jeden Anbieter in einer eigenen Karte konfigurieren. Anbieter können für mehrere API-Schlüssel wiederholt werden; die aktive Karte wird für die Inhaltserstellung verwendet."},
200
+ "vi": {"LLM principal": "LLM chính", "Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.": "Dùng thẻ này làm nhà cung cấp chính cho chủ đề, kịch bản, tiêu đề và từ khóa.", "Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.": "Chỉ dùng thẻ này để định tuyến thông báo Telegram. Chỉ được chọn một thẻ.", "Account Id": "ID tài khoản", "Gateway Id": "ID cổng", "Api Version": "Phiên bản API", "Endpoint gerido pelo provider: ": "Endpoint do nhà cung cấp quản lý: ", "Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.": "Chỉ hiển thị trường này với nhà cung cấp có endpoint OpenAI tương thích tùy chỉnh.", "ID do modelo usado pelo endpoint Chat Completions.": "ID mô hình được endpoint Chat Completions sử dụng.", "Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo.": "Định cấu hình từng nhà cung cấp trong thẻ riêng. Có thể lặp lại nhà cung cấp cho nhiều khóa API; thẻ đang hoạt động được dùng để tạo nội dung."},
201
+ "tr": {"LLM principal": "Birincil LLM", "Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.": "Konu, senaryo, başlık ve anahtar kelimeler için bu kartı birincil sağlayıcı olarak kullanın.", "Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.": "Bu kartı yalnızca Telegram bildirim yönlendirmesi için kullanın. Yalnızca bir kart seçilebilir.", "Account Id": "Hesap Kimliği", "Gateway Id": "Ağ Geçidi Kimliği", "Api Version": "API sürümü", "Endpoint gerido pelo provider: ": "Sağlayıcı tarafından yönetilen uç nokta: ", "Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.": "Bu alan yalnızca yapılandırılabilir OpenAI uyumlu uç noktası olan sağlayıcılarda görünür.", "ID do modelo usado pelo endpoint Chat Completions.": "Chat Completions uç noktası tarafından kullanılan model kimliği.", "Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo.": "Her sağlayıcıyı bağımsız bir kartta yapılandırın. Bir sağlayıcı birden fazla API anahtarı için tekrarlanabilir; etkin kart içerik üretiminde kullanılır."},
202
+ "ru": {"LLM principal": "Основной LLM", "Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.": "Использовать эту карточку как основной провайдер для тем, сценариев, заголовков и ключевых слов.", "Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.": "Использовать эту карточку только для маршрутизации уведомлений Telegram. Можно выбрать только одну карточку.", "Account Id": "ID аккаунта", "Gateway Id": "ID шлюза", "Api Version": "Версия API", "Endpoint gerido pelo provider: ": "Эндпоинт под управлением провайдера: ", "Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.": "Поле отображается только для провайдеров с настраиваемым OpenAI-совместимым эндпоинтом.", "ID do modelo usado pelo endpoint Chat Completions.": "ID модели, используемый эндпоинтом Chat Completions.", "Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo.": "Настройте каждого провайдера в отдельной карточке. Провайдера можно повторить для нескольких API-ключей; активная карточка используется для генерации контента."},
203
+ "es": {"LLM principal": "LLM principal", "Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.": "Usar esta tarjeta como proveedor principal para temas, guiones, títulos y palabras clave.", "Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.": "Usar esta tarjeta exclusivamente para enrutar notificaciones de Telegram. Solo se puede seleccionar una tarjeta.", "Account Id": "ID de cuenta", "Gateway Id": "ID de puerta de enlace", "Api Version": "Versión de API", "Endpoint gerido pelo provider: ": "Endpoint gestionado por el proveedor: ", "Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.": "Este campo solo aparece para proveedores con un endpoint OpenAI compatible configurable.", "ID do modelo usado pelo endpoint Chat Completions.": "ID del modelo usado por el endpoint Chat Completions.", "Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo.": "Configura cada proveedor en una tarjeta independiente. Puedes repetir un proveedor para varias claves API; la tarjeta activa se usa para generar contenido."},
204
+ "id": {"LLM principal": "LLM utama", "Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.": "Gunakan kartu ini sebagai penyedia utama untuk topik, skrip, judul, dan kata kunci.", "Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.": "Gunakan kartu ini khusus untuk perutean notifikasi Telegram. Hanya satu kartu yang dapat dipilih.", "Account Id": "ID akun", "Gateway Id": "ID gateway", "Api Version": "Versi API", "Endpoint gerido pelo provider: ": "Endpoint yang dikelola penyedia: ", "Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.": "Bidang ini hanya tampil untuk penyedia dengan endpoint OpenAI-compatible yang dapat dikonfigurasi.", "ID do modelo usado pelo endpoint Chat Completions.": "ID model yang digunakan endpoint Chat Completions.", "Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo.": "Konfigurasikan setiap penyedia dalam kartu terpisah. Penyedia dapat diulang untuk beberapa kunci API; kartu aktif digunakan untuk pembuatan konten."},
205
+ "it": {"LLM principal": "LLM principale", "Usar este cartão como provider principal para geração de temas, roteiros, títulos e keywords.": "Usa questa scheda come provider principale per argomenti, copioni, titoli e parole chiave.", "Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.": "Usa questa scheda esclusivamente per il routing delle notifiche Telegram. È possibile selezionare una sola scheda.", "Account Id": "ID account", "Gateway Id": "ID gateway", "Api Version": "Versione API", "Endpoint gerido pelo provider: ": "Endpoint gestito dal provider: ", "Endpoint OpenAI-compatible deste provider; só aparece quando é configurável.": "Il campo appare solo per provider con endpoint OpenAI compatibile configurabile.", "ID do modelo usado pelo endpoint Chat Completions.": "ID del modello usato dall'endpoint Chat Completions.", "Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; o cartão activo é usado pela geração de conteúdo.": "Configura ogni provider in una scheda indipendente. Puoi ripetere un provider per più chiavi API; la scheda attiva viene usata per generare contenuti."},
206
+ }
207
+ for _language_code, _llm_control_values in _LLM_CONTROL_TRANSLATIONS.items():
208
+ UI_TRANSLATIONS[_language_code].update(_llm_control_values)
209
+
210
+
144
211
  # Labels introduced by the reorganised sidebar. They are merged into the
145
212
  # existing UI translation index so every navigation control uses the same
146
213
  # translation path as the legacy pages.
@@ -191,6 +258,72 @@ for _language_code, _backlog_label in _BACKLOG_CANONICAL_LABELS.items():
191
258
  UI_TRANSLATIONS[_language_code]["Backlog Videos"] = _backlog_label
192
259
 
193
260
 
261
+ _GLOBAL_INNERTUBE_TRANSLATIONS: dict[str, dict[str, str]] = {
262
+ "pt": {
263
+ "Esta é uma chave API global do YouTube: aplica-se a todas as contas Google/YouTube e a todo o sistema. Não é associada a uma conta específica, não faz parte dos documentos de cookies/credenciais e não é editada no separador API Keys.": "Esta é uma chave API global do YouTube: aplica-se a todas as contas Google/YouTube e a todo o sistema. Não é associada a uma conta específica, não faz parte dos documentos de cookies/credenciais e não é editada no separador API Keys.",
264
+ "Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.": "Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.",
265
+ "Guardar INNERTUBE_API_KEY global": "Guardar INNERTUBE_API_KEY global",
266
+ "INNERTUBE_API_KEY global guardada para todas as contas Google/YouTube e para todo o sistema.": "INNERTUBE_API_KEY global guardada para todas as contas Google/YouTube e para todo o sistema.",
267
+ },
268
+ "en": {
269
+ "Esta é uma chave API global do YouTube: aplica-se a todas as contas Google/YouTube e a todo o sistema. Não é associada a uma conta específica, não faz parte dos documentos de cookies/credenciais e não é editada no separador API Keys.": "This is a global YouTube API key: it applies to every Google/YouTube account and the entire system. It is not associated with a specific account, is not part of cookie/credential documents, and is not edited in the API Keys tab.",
270
+ "Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.": "Global key used by direct upload for all Google/YouTube accounts. Save it in the global configuration, separately from cookie documents.",
271
+ "Guardar INNERTUBE_API_KEY global": "Save global INNERTUBE_API_KEY",
272
+ "INNERTUBE_API_KEY global guardada para todas as contas Google/YouTube e para todo o sistema.": "Global INNERTUBE_API_KEY saved for all Google/YouTube accounts and the entire system.",
273
+ },
274
+ "zh": {
275
+ "Esta é uma chave API global do YouTube: aplica-se a todas as contas Google/YouTube e a todo o sistema. Não é associada a uma conta específica, não faz parte dos documentos de cookies/credenciais e não é editada no separador API Keys.": "这是 YouTube 的全局 API 密钥:适用于所有 Google/YouTube 账户和整个系统。它不关联特定账户,不属于 Cookie/凭证文档,也不在 API 密钥选项卡中编辑。",
276
+ "Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.": "直接上传为所有 Google/YouTube 账户使用的全局密钥。请将其保存在全局配置中,与 Cookie 文档分开。",
277
+ "Guardar INNERTUBE_API_KEY global": "保存全局 INNERTUBE_API_KEY",
278
+ "INNERTUBE_API_KEY global guardada para todas as contas Google/YouTube e para todo o sistema.": "全局 INNERTUBE_API_KEY 已为所有 Google/YouTube 账户和整个系统保存。",
279
+ },
280
+ "de": {
281
+ "Esta é uma chave API global do YouTube: aplica-se a todas as contas Google/YouTube e a todo o sistema. Não é associada a uma conta específica, não faz parte dos documentos de cookies/credenciais e não é editada no separador API Keys.": "Dies ist ein globaler YouTube-API-Schlüssel: Er gilt für alle Google/YouTube-Konten und das gesamte System. Er ist keinem bestimmten Konto zugeordnet, gehört nicht zu Cookie-/Anmeldedokumenten und wird nicht im Tab API-Schlüssel bearbeitet.",
282
+ "Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.": "Globaler Schlüssel für den direkten Upload mit allen Google/YouTube-Konten. Speichern Sie ihn in der globalen Konfiguration getrennt von den Cookie-Dokumenten.",
283
+ "Guardar INNERTUBE_API_KEY global": "Globale INNERTUBE_API_KEY speichern",
284
+ "INNERTUBE_API_KEY global guardada para todas as contas Google/YouTube e para todo o sistema.": "Globale INNERTUBE_API_KEY für alle Google/YouTube-Konten und das gesamte System gespeichert.",
285
+ },
286
+ "vi": {
287
+ "Esta é uma chave API global do YouTube: aplica-se a todas as contas Google/YouTube e a todo o sistema. Não é associada a uma conta específica, não faz parte dos documentos de cookies/credenciais e não é editada no separador API Keys.": "Đây là khóa API YouTube toàn cục: áp dụng cho mọi tài khoản Google/YouTube và toàn bộ hệ thống. Khóa không gắn với tài khoản cụ thể, không thuộc tài liệu cookie/thông tin xác thực và không được chỉnh sửa trong tab API Keys.",
288
+ "Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.": "Khóa toàn cục dùng cho tải lên trực tiếp với mọi tài khoản Google/YouTube. Lưu khóa trong cấu hình toàn cục, tách khỏi tài liệu cookie.",
289
+ "Guardar INNERTUBE_API_KEY global": "Lưu INNERTUBE_API_KEY toàn cục",
290
+ "INNERTUBE_API_KEY global guardada para todas as contas Google/YouTube e para todo o sistema.": "Đã lưu INNERTUBE_API_KEY toàn cục cho mọi tài khoản Google/YouTube và toàn hệ thống.",
291
+ },
292
+ "tr": {
293
+ "Esta é uma chave API global do YouTube: aplica-se a todas as contas Google/YouTube e a todo o sistema. Não é associada a uma conta específica, não faz parte dos documentos de cookies/credenciais e não é editada no separador API Keys.": "Bu, tüm Google/YouTube hesapları ve sistemin tamamı için geçerli global bir YouTube API anahtarıdır. Belirli bir hesaba bağlı değildir, çerez/kimlik bilgisi belgelerinin parçası değildir ve API Anahtarları sekmesinde düzenlenmez.",
294
+ "Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.": "Doğrudan yüklemede tüm Google/YouTube hesapları için kullanılan global anahtar. Çerez belgelerinden ayrı olarak global yapılandırmaya kaydedin.",
295
+ "Guardar INNERTUBE_API_KEY global": "Global INNERTUBE_API_KEY kaydet",
296
+ "INNERTUBE_API_KEY global guardada para todas as contas Google/YouTube e para todo o sistema.": "Global INNERTUBE_API_KEY tüm Google/YouTube hesapları ve sistem için kaydedildi.",
297
+ },
298
+ "ru": {
299
+ "Esta é uma chave API global do YouTube: aplica-se a todas as contas Google/YouTube e a todo o sistema. Não é associada a uma conta específica, não faz parte dos documentos de cookies/credenciais e não é editada no separador API Keys.": "Это глобальный API-ключ YouTube: он применяется ко всем аккаунтам Google/YouTube и ко всей системе. Он не связан с конкретным аккаунтом, не входит в документы cookie/учётных данных и не редактируется на вкладке API-ключей.",
300
+ "Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.": "Глобальный ключ для прямой загрузки со всех аккаунтов Google/YouTube. Сохраните его в глобальной конфигурации отдельно от документов cookie.",
301
+ "Guardar INNERTUBE_API_KEY global": "Сохранить глобальный INNERTUBE_API_KEY",
302
+ "INNERTUBE_API_KEY global guardada para todas as contas Google/YouTube e para todo o sistema.": "Глобальный INNERTUBE_API_KEY сохранён для всех аккаунтов Google/YouTube и всей системы.",
303
+ },
304
+ "es": {
305
+ "Esta é uma chave API global do YouTube: aplica-se a todas as contas Google/YouTube e a todo o sistema. Não é associada a uma conta específica, não faz parte dos documentos de cookies/credenciais e não é editada no separador API Keys.": "Esta es una clave API global de YouTube: se aplica a todas las cuentas de Google/YouTube y a todo el sistema. No está asociada a una cuenta concreta, no forma parte de los documentos de cookies/credenciales y no se edita en la pestaña Claves API.",
306
+ "Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.": "Clave global utilizada por la carga directa para todas las cuentas de Google/YouTube. Guárdela en la configuración global, separada de los documentos de cookies.",
307
+ "Guardar INNERTUBE_API_KEY global": "Guardar INNERTUBE_API_KEY global",
308
+ "INNERTUBE_API_KEY global guardada para todas as contas Google/YouTube e para todo o sistema.": "INNERTUBE_API_KEY global guardada para todas las cuentas de Google/YouTube y todo el sistema.",
309
+ },
310
+ "id": {
311
+ "Esta é uma chave API global do YouTube: aplica-se a todas as contas Google/YouTube e a todo o sistema. Não é associada a uma conta específica, não faz parte dos documentos de cookies/credenciais e não é editada no separador API Keys.": "Ini adalah kunci API YouTube global: berlaku untuk semua akun Google/YouTube dan seluruh sistem. Kunci ini tidak terkait dengan akun tertentu, bukan bagian dari dokumen cookie/kredensial, dan tidak diedit di tab Kunci API.",
312
+ "Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.": "Kunci global yang digunakan unggah langsung untuk semua akun Google/YouTube. Simpan di konfigurasi global, terpisah dari dokumen cookie.",
313
+ "Guardar INNERTUBE_API_KEY global": "Simpan INNERTUBE_API_KEY global",
314
+ "INNERTUBE_API_KEY global guardada para todas as contas Google/YouTube e para todo o sistema.": "INNERTUBE_API_KEY global disimpan untuk semua akun Google/YouTube dan seluruh sistem.",
315
+ },
316
+ "it": {
317
+ "Esta é uma chave API global do YouTube: aplica-se a todas as contas Google/YouTube e a todo o sistema. Não é associada a uma conta específica, não faz parte dos documentos de cookies/credenciais e não é editada no separador API Keys.": "Questa è una chiave API globale di YouTube: si applica a tutti gli account Google/YouTube e all'intero sistema. Non è associata a un account specifico, non fa parte dei documenti di cookie/credenziali e non viene modificata nella scheda Chiavi API.",
318
+ "Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.": "Chiave globale usata dal caricamento diretto per tutti gli account Google/YouTube. Salvala nella configurazione globale, separata dai documenti cookie.",
319
+ "Guardar INNERTUBE_API_KEY global": "Salva INNERTUBE_API_KEY globale",
320
+ "INNERTUBE_API_KEY global guardada para todas as contas Google/YouTube e para todo o sistema.": "INNERTUBE_API_KEY globale salvata per tutti gli account Google/YouTube e per l'intero sistema.",
321
+ },
322
+ }
323
+ for _language_code, _global_innertube_translation in _GLOBAL_INNERTUBE_TRANSLATIONS.items():
324
+ UI_TRANSLATIONS[_language_code].update(_global_innertube_translation)
325
+
326
+
194
327
  VIDEO_GENERATION_TRANSLATIONS: dict[str, dict[str, str]] = {
195
328
  "pt": {
196
329
  "Gerar tópico, roteiro e palavras-chave com IA": "Gerar tópico, roteiro e palavras-chave com IA",
@@ -0,0 +1,414 @@
1
+ """Catálogo e compatibilidade dos cartões de providers LLM do Thunderbolt.
2
+
3
+ Este módulo não conhece Streamlit. Mantém o formato novo de cartões separado da UI,
4
+ conserva os campos legados usados pelo pipeline e centraliza a validação do estado.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Mapping
12
+ from urllib.parse import urlsplit, urlunsplit
13
+
14
+ from integrations.openai_model_discovery import (
15
+ DEFAULT_NVIDIA_NIM_BASE_URL,
16
+ ModelDiscoveryError,
17
+ fetch_openai_compatible_models,
18
+ )
19
+
20
+
21
+ LLM_CARDS_KEY = "llm_provider_cards"
22
+ LLM_ACTIVE_CARD_KEY = "llm_active_card_id"
23
+ LLM_TELEGRAM_CARD_KEY = "llm_telegram_card_id"
24
+ DEFAULT_LLM_PROVIDER = "openai"
25
+ DEFAULT_LLM_CARD_ID = "llm-openai-default"
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class LlmProviderDefinition:
30
+ code: str
31
+ label: str
32
+ requires_api_key: bool = True
33
+ show_base_url: bool = False
34
+ default_base_url: str = ""
35
+ local: bool = False
36
+ supports_model_discovery: bool = True
37
+ extra_fields: tuple[str, ...] = ()
38
+ description: str = ""
39
+
40
+
41
+ # Só estão aqui providers que o adaptador OpenAI-compatible existente consegue
42
+ # consumir, ou que são explicitamente apresentados como locais/customizáveis. A
43
+ # lista é extensível sem alterar o schema dos cartões.
44
+ LLM_PROVIDER_CATALOG: tuple[LlmProviderDefinition, ...] = (
45
+ LlmProviderDefinition(
46
+ "openai",
47
+ "OpenAI / NVIDIA NIM",
48
+ show_base_url=True,
49
+ default_base_url=DEFAULT_NVIDIA_NIM_BASE_URL,
50
+ description="OpenAI ou NVIDIA NIM através do protocolo OpenAI-compatible.",
51
+ ),
52
+ LlmProviderDefinition("openrouter", "OpenRouter", default_base_url="https://openrouter.ai/api/v1"),
53
+ LlmProviderDefinition("moonshot", "Moonshot / Kimi", default_base_url="https://api.moonshot.ai/v1"),
54
+ LlmProviderDefinition("shengsuanyun", "Shengsuan Cloud", show_base_url=True),
55
+ LlmProviderDefinition(
56
+ "gemini",
57
+ "Google Gemini",
58
+ default_base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
59
+ ),
60
+ LlmProviderDefinition("deepseek", "DeepSeek", default_base_url="https://api.deepseek.com"),
61
+ LlmProviderDefinition("azure", "Azure OpenAI", show_base_url=True, extra_fields=("api_version",), description="Requer a Base URL do recurso Azure e a versão da API."),
62
+ LlmProviderDefinition(
63
+ "qwen",
64
+ "Alibaba Qwen / DashScope",
65
+ default_base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
66
+ ),
67
+ LlmProviderDefinition("mistral", "Mistral", default_base_url="https://api.mistral.ai/v1"),
68
+ LlmProviderDefinition("groq", "Groq", default_base_url="https://api.groq.com/openai/v1"),
69
+ LlmProviderDefinition("grok", "xAI Grok", default_base_url="https://api.x.ai/v1"),
70
+ LlmProviderDefinition("volcengine", "VolcEngine Ark", default_base_url="https://ark.cn-beijing.volces.com/api/v3"),
71
+ LlmProviderDefinition("minimax", "MiniMax", default_base_url="https://api.minimax.io/v1"),
72
+ LlmProviderDefinition("mimo", "Xiaomi MiMo", show_base_url=True),
73
+ LlmProviderDefinition(
74
+ "cloudflare",
75
+ "Cloudflare Workers AI",
76
+ show_base_url=False,
77
+ supports_model_discovery=False,
78
+ extra_fields=("account_id", "gateway_id"),
79
+ description="Requer Account ID; o endpoint é construído pelo adaptador Cloudflare.",
80
+ ),
81
+ LlmProviderDefinition("modelscope", "ModelScope", show_base_url=True),
82
+ LlmProviderDefinition("aihubmix", "AIHubMix", default_base_url="https://aihubmix.com/v1"),
83
+ LlmProviderDefinition("aimlapi", "AIML API", default_base_url="https://api.aimlapi.com/v1"),
84
+ LlmProviderDefinition("evolink", "EvoLink", default_base_url="https://api.evolink.ai/v1"),
85
+ LlmProviderDefinition(
86
+ "ollama",
87
+ "Ollama",
88
+ requires_api_key=False,
89
+ show_base_url=True,
90
+ default_base_url="http://127.0.0.1:11434/v1",
91
+ local=True,
92
+ ),
93
+ LlmProviderDefinition(
94
+ "lmstudio",
95
+ "LM Studio",
96
+ requires_api_key=False,
97
+ show_base_url=True,
98
+ default_base_url="http://127.0.0.1:1234/v1",
99
+ local=True,
100
+ ),
101
+ LlmProviderDefinition(
102
+ "llamacpp",
103
+ "llama.cpp",
104
+ requires_api_key=False,
105
+ show_base_url=True,
106
+ default_base_url="http://127.0.0.1:8080/v1",
107
+ local=True,
108
+ ),
109
+ LlmProviderDefinition("oneapi", "OneAPI", show_base_url=True),
110
+ LlmProviderDefinition("litellm", "LiteLLM Proxy", requires_api_key=False, show_base_url=True),
111
+ LlmProviderDefinition(
112
+ "pollinations",
113
+ "Pollinations AI",
114
+ requires_api_key=True,
115
+ default_base_url="https://gen.pollinations.ai/v1",
116
+ ),
117
+ LlmProviderDefinition("together", "Together AI", default_base_url="https://api.together.ai/v1"),
118
+ LlmProviderDefinition("fireworks", "Fireworks AI", default_base_url="https://api.fireworks.ai/inference/v1"),
119
+ LlmProviderDefinition("siliconflow", "SiliconFlow", default_base_url="https://api.siliconflow.com/v1"),
120
+ LlmProviderDefinition("cohere", "Cohere", default_base_url="https://api.cohere.ai/compatibility/v1"),
121
+ LlmProviderDefinition("huggingface", "Hugging Face", default_base_url="https://router.huggingface.co/v1"),
122
+ LlmProviderDefinition("github_models", "GitHub Models", default_base_url="https://models.github.ai/inference"),
123
+ LlmProviderDefinition("cerebras", "Cerebras", default_base_url="https://api.cerebras.ai/v1"),
124
+ LlmProviderDefinition("sambanova", "SambaNova", default_base_url="https://api.sambanova.ai/v1"),
125
+ LlmProviderDefinition("perplexity", "Perplexity", default_base_url="https://api.perplexity.ai"),
126
+ LlmProviderDefinition("openai_compatible", "OpenAI-compatible personalizado", show_base_url=True),
127
+ )
128
+
129
+ _PROVIDER_BY_CODE = {item.code: item for item in LLM_PROVIDER_CATALOG}
130
+ _PROVIDER_ALIASES = {
131
+ "open_router": "openrouter",
132
+ "xai": "grok",
133
+ "google": "gemini",
134
+ "google_gemini": "gemini",
135
+ "azure_openai": "azure",
136
+ "custom": "openai_compatible",
137
+ }
138
+ _LEGACY_PREFIX = {
139
+ "openrouter": "openrouter",
140
+ "openai_compatible": "openai",
141
+ "github_models": "github_models",
142
+ }
143
+
144
+
145
+ def provider_definition(provider: Any) -> LlmProviderDefinition:
146
+ code = normalize_provider_code(provider)
147
+ return _PROVIDER_BY_CODE.get(code, _PROVIDER_BY_CODE["openai_compatible"])
148
+
149
+
150
+ def normalize_provider_code(provider: Any) -> str:
151
+ value = str(provider or "").strip().lower().replace(" ", "_")
152
+ return _PROVIDER_ALIASES.get(value, value or DEFAULT_LLM_PROVIDER)
153
+
154
+
155
+ def _legacy_prefix(provider: str) -> str:
156
+ return _LEGACY_PREFIX.get(provider, provider)
157
+
158
+
159
+ def _legacy_value(settings: Mapping[str, Any], provider: str, suffix: str) -> str:
160
+ prefix = _legacy_prefix(provider)
161
+ return str(settings.get(f"{prefix}_{suffix}") or "").strip()
162
+
163
+
164
+ def _legacy_card(settings: Mapping[str, Any], provider: str, card_id: str) -> dict[str, Any]:
165
+ definition = provider_definition(provider)
166
+ code = normalize_provider_code(provider)
167
+ base_url = _legacy_value(settings, code, "base_url") or definition.default_base_url
168
+ api_key = _legacy_value(settings, code, "api_key")
169
+ model = _legacy_value(settings, code, "model_name")
170
+ extra: dict[str, str] = {}
171
+ if code == "cloudflare":
172
+ extra = {
173
+ "account_id": str(settings.get("cloudflare_account_id") or "").strip(),
174
+ "gateway_id": str(settings.get("cloudflare_gateway_id") or "").strip(),
175
+ }
176
+ return {
177
+ "id": card_id,
178
+ "provider": code,
179
+ "api_key": api_key,
180
+ "model": model,
181
+ "base_url": base_url,
182
+ "enabled": True,
183
+ "telegram_llm": False,
184
+ **extra,
185
+ }
186
+
187
+
188
+ def new_llm_card(provider: Any, *, card_id: str | None = None) -> dict[str, Any]:
189
+ code = normalize_provider_code(provider)
190
+ definition = provider_definition(code)
191
+ return {
192
+ "id": str(card_id or "llm-" + code),
193
+ "provider": code,
194
+ "api_key": "",
195
+ "model": "",
196
+ "base_url": definition.default_base_url,
197
+ "enabled": True,
198
+ "telegram_llm": False,
199
+ **{field: "" for field in definition.extra_fields},
200
+ }
201
+
202
+
203
+ def _clean_test_result(value: Any) -> dict[str, Any]:
204
+ if not isinstance(value, Mapping):
205
+ return {}
206
+ status = str(value.get("status") or "").strip().lower()
207
+ if status not in {"success", "error"}:
208
+ return {}
209
+ return {
210
+ "status": status,
211
+ "message": str(value.get("message") or "")[:240],
212
+ "tested_at": str(value.get("tested_at") or "")[:64],
213
+ }
214
+
215
+
216
+ def normalize_llm_card(card: Any, index: int = 0) -> dict[str, Any]:
217
+ source = dict(card) if isinstance(card, Mapping) else {}
218
+ code = normalize_provider_code(source.get("provider") or source.get("provider_code"))
219
+ definition = provider_definition(code)
220
+ raw_id = str(source.get("id") or "").strip()
221
+ card_id = raw_id or (DEFAULT_LLM_CARD_ID if code == "openai" and index == 0 else f"llm-{code}-{index + 1}")
222
+ result: dict[str, Any] = {
223
+ "id": card_id,
224
+ "provider": code,
225
+ "api_key": str(source.get("api_key") or source.get("key") or "").strip(),
226
+ "model": str(source.get("model") or source.get("model_name") or "").strip(),
227
+ "base_url": str(source.get("base_url") or "").strip() or definition.default_base_url,
228
+ "enabled": bool(source.get("enabled", True)),
229
+ "telegram_llm": bool(source.get("telegram_llm", source.get("llm_telegram", False))),
230
+ }
231
+ for field in definition.extra_fields:
232
+ result[field] = str(source.get(field) or "").strip()
233
+ test_result = _clean_test_result(source.get("test_result"))
234
+ if test_result:
235
+ result["test_result"] = test_result
236
+ return result
237
+
238
+
239
+ def _card_has_legacy_configuration(settings: Mapping[str, Any], provider: str) -> bool:
240
+ definition = provider_definition(provider)
241
+ values = [_legacy_value(settings, provider, "api_key"), _legacy_value(settings, provider, "model_name")]
242
+ if definition.show_base_url:
243
+ values.append(_legacy_value(settings, provider, "base_url"))
244
+ values.extend(str(settings.get(f"{provider}_{field}") or "").strip() for field in definition.extra_fields)
245
+ return any(values)
246
+
247
+
248
+ def ensure_llm_provider_cards(settings: Mapping[str, Any]) -> tuple[dict[str, Any], bool]:
249
+ """Return settings with a stable card collection and legacy values untouched."""
250
+ result = dict(settings) if isinstance(settings, Mapping) else {}
251
+ raw_cards = result.get(LLM_CARDS_KEY)
252
+ cards = [normalize_llm_card(item, index) for index, item in enumerate(raw_cards)] if isinstance(raw_cards, list) else []
253
+ changed = not isinstance(raw_cards, list)
254
+
255
+ # O primeiro card OpenAI/NVIDIA NIM é estrutural e nunca é removido pela migração.
256
+ if not any(card.get("provider") == DEFAULT_LLM_PROVIDER for card in cards):
257
+ cards.insert(0, _legacy_card(result, DEFAULT_LLM_PROVIDER, DEFAULT_LLM_CARD_ID))
258
+ changed = True
259
+ elif cards[0].get("provider") != DEFAULT_LLM_PROVIDER:
260
+ openai_card = next(card for card in cards if card.get("provider") == DEFAULT_LLM_PROVIDER)
261
+ cards.remove(openai_card)
262
+ cards.insert(0, openai_card)
263
+ changed = True
264
+
265
+ selected_provider = normalize_provider_code(result.get("llm_provider") or DEFAULT_LLM_PROVIDER)
266
+ if not str(result.get("llm_provider") or "").strip():
267
+ result["llm_provider"] = DEFAULT_LLM_PROVIDER
268
+ changed = True
269
+ # A migração preserva uma configuração explícita antiga que ainda não tinha card.
270
+ if selected_provider != DEFAULT_LLM_PROVIDER and _card_has_legacy_configuration(result, selected_provider):
271
+ if not any(card.get("provider") == selected_provider for card in cards):
272
+ cards.append(_legacy_card(result, selected_provider, f"llm-{selected_provider}-legacy"))
273
+ changed = True
274
+
275
+ serialized_before = raw_cards if isinstance(raw_cards, list) else None
276
+ if serialized_before != cards:
277
+ changed = True
278
+ result[LLM_CARDS_KEY] = cards
279
+
280
+ active_id = str(result.get(LLM_ACTIVE_CARD_KEY) or "").strip()
281
+ ids = {str(card.get("id")) for card in cards}
282
+ if active_id not in ids:
283
+ matching = next((card for card in cards if card.get("provider") == selected_provider), cards[0])
284
+ result[LLM_ACTIVE_CARD_KEY] = str(matching["id"])
285
+ changed = True
286
+ if not any(card.get("telegram_llm") for card in cards):
287
+ if result.get(LLM_TELEGRAM_CARD_KEY):
288
+ result[LLM_TELEGRAM_CARD_KEY] = ""
289
+ changed = True
290
+ else:
291
+ telegram_card = next(card for card in cards if card.get("telegram_llm"))
292
+ if result.get(LLM_TELEGRAM_CARD_KEY) != telegram_card["id"]:
293
+ result[LLM_TELEGRAM_CARD_KEY] = telegram_card["id"]
294
+ changed = True
295
+ return result, changed
296
+
297
+
298
+ def llm_cards(settings: Mapping[str, Any]) -> list[dict[str, Any]]:
299
+ migrated, _ = ensure_llm_provider_cards(settings)
300
+ return [dict(card) for card in migrated.get(LLM_CARDS_KEY, [])]
301
+
302
+
303
+ def active_llm_card(settings: Mapping[str, Any]) -> dict[str, Any]:
304
+ migrated, _ = ensure_llm_provider_cards(settings)
305
+ cards = migrated.get(LLM_CARDS_KEY, [])
306
+ active_id = str(migrated.get(LLM_ACTIVE_CARD_KEY) or "")
307
+ for card in cards:
308
+ if str(card.get("id")) == active_id and card.get("enabled", True):
309
+ return dict(card)
310
+ for card in cards:
311
+ if card.get("enabled", True):
312
+ return dict(card)
313
+ return new_llm_card(DEFAULT_LLM_PROVIDER, card_id=DEFAULT_LLM_CARD_ID)
314
+
315
+
316
+ def telegram_llm_card(settings: Mapping[str, Any]) -> dict[str, Any] | None:
317
+ for card in llm_cards(settings):
318
+ if card.get("telegram_llm") and card.get("enabled", True):
319
+ return card
320
+ return None
321
+
322
+
323
+ def apply_llm_cards_to_settings(
324
+ settings: Mapping[str, Any], cards: list[Mapping[str, Any]], active_card_id: str = ""
325
+ ) -> dict[str, Any]:
326
+ """Persist cards and mirror the active card to the legacy runtime contract."""
327
+ result = dict(settings)
328
+ normalized = [normalize_llm_card(item, index) for index, item in enumerate(cards)]
329
+ if not normalized or not any(card.get("provider") == DEFAULT_LLM_PROVIDER for card in normalized):
330
+ normalized.insert(0, _legacy_card(result, DEFAULT_LLM_PROVIDER, DEFAULT_LLM_CARD_ID))
331
+
332
+ # Checkbox Telegram é exclusivo: o último card marcado vence e todos os outros
333
+ # ficam desmarcados. O ID explícito torna o roteamento determinístico.
334
+ telegram = next((card for card in reversed(normalized) if card.get("telegram_llm")), None)
335
+ for card in normalized:
336
+ card["telegram_llm"] = bool(telegram and card["id"] == telegram["id"])
337
+
338
+ wanted_id = str(active_card_id or result.get(LLM_ACTIVE_CARD_KEY) or "").strip()
339
+ active = next((card for card in normalized if card["id"] == wanted_id), None)
340
+ if active is None or not active.get("enabled", True):
341
+ active = next((card for card in normalized if card.get("enabled", True)), normalized[0])
342
+ result[LLM_CARDS_KEY] = normalized
343
+ result[LLM_ACTIVE_CARD_KEY] = active["id"]
344
+ result[LLM_TELEGRAM_CARD_KEY] = telegram["id"] if telegram else ""
345
+ result["llm_provider"] = active["provider"]
346
+
347
+ prefix = _legacy_prefix(str(active["provider"]))
348
+ result[f"{prefix}_api_key"] = str(active.get("api_key") or "").strip()
349
+ result[f"{prefix}_model_name"] = str(active.get("model") or "").strip()
350
+ result[f"{prefix}_base_url"] = str(active.get("base_url") or "").strip()
351
+ definition = provider_definition(active["provider"])
352
+ for field in definition.extra_fields:
353
+ result[f"{prefix}_{field}"] = str(active.get(field) or "").strip()
354
+ if active.get("provider") == "cloudflare":
355
+ result["cloudflare_account_id"] = str(active.get("account_id") or "").strip()
356
+ result["cloudflare_gateway_id"] = str(active.get("gateway_id") or "").strip()
357
+ return result
358
+
359
+
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
+ def _redact(text: Any, secrets: tuple[str, ...]) -> str:
369
+ message = str(text or "")
370
+ for secret in secrets:
371
+ if secret:
372
+ message = message.replace(secret, "[REDACTED]")
373
+ return message[:240]
374
+
375
+
376
+ def test_llm_provider_card(card: Mapping[str, Any]) -> dict[str, Any]:
377
+ """Perform a bounded provider check without returning secrets or raw exceptions."""
378
+ normalized = normalize_llm_card(card)
379
+ definition = provider_definition(normalized["provider"])
380
+ api_key = str(normalized.get("api_key") or "").strip()
381
+ base_url = str(normalized.get("base_url") or "").strip()
382
+ secrets = (api_key,)
383
+ if definition.requires_api_key and not api_key:
384
+ return {"ok": False, "status": "error", "message": "Missing key — introduza a API key antes do teste."}
385
+ if definition.show_base_url and not base_url:
386
+ return {"ok": False, "status": "error", "message": "Missing configuration — introduza a Base URL antes do teste."}
387
+ if not base_url:
388
+ 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."}
391
+ try:
392
+ models = fetch_openai_compatible_models(api_key, base_url)
393
+ except ModelDiscoveryError as exc:
394
+ return {
395
+ "ok": False,
396
+ "status": "error",
397
+ "message": _redact(str(exc), secrets),
398
+ }
399
+ except Exception:
400
+ return {"ok": False, "status": "error", "message": "A chamada de teste falhou de forma inesperada."}
401
+ return {
402
+ "ok": True,
403
+ "status": "success",
404
+ "message": f"API OK — {_safe_url(base_url)} respondeu com {len(models)} modelo(s).",
405
+ }
406
+
407
+
408
+ def stamp_test_result(result: Mapping[str, Any]) -> dict[str, Any]:
409
+ status = "success" if result.get("ok") else "error"
410
+ return {
411
+ "status": status,
412
+ "message": str(result.get("message") or "")[:240],
413
+ "tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
414
+ }
@@ -14,6 +14,7 @@ from hermes_ui.creative_generation import CreativeGenerationError, generate_crea
14
14
  from hermes_ui.script_documents import save_script_document
15
15
  from hermes_ui.script_generation import generate_script_document
16
16
  from hermes_ui.storage import STORAGE, ensure_storage, read_json, write_json
17
+ from hermes_ui.llm_providers import active_llm_card, provider_definition
17
18
  from hermes_ui.thumbnail_generation import generate_thumbnail_image
18
19
 
19
20
  PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
@@ -120,12 +121,14 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
120
121
  raise PipelineError("A etapa Vídeo não recebeu um tema válido.")
121
122
  settings = _settings()
122
123
  env = os.environ.copy()
123
- provider = str(settings.get("llm_provider") or "openai").strip()
124
+ card = active_llm_card(settings)
125
+ provider = str(card.get("provider") or "openai").strip()
126
+ definition = provider_definition(provider)
124
127
  env_values = {
125
128
  "MPT_LLM_PROVIDER": provider,
126
- "MPT_LLM_API_KEY": str(settings.get(f"{provider}_api_key") or "").strip(),
127
- "MPT_LLM_BASE_URL": str(settings.get(f"{provider}_base_url") or "").strip(),
128
- "MPT_LLM_MODEL_NAME": str(settings.get(f"{provider}_model_name") or "").strip(),
129
+ "MPT_LLM_API_KEY": str(card.get("api_key") or "").strip(),
130
+ "MPT_LLM_BASE_URL": str(card.get("base_url") or definition.default_base_url or "").strip(),
131
+ "MPT_LLM_MODEL_NAME": str(card.get("model") or "").strip(),
129
132
  "MPT_PEXELS_API_KEY": str(settings.get("pexels_api_key") or "").strip(),
130
133
  }
131
134
  for key, value in env_values.items():
@@ -128,6 +128,17 @@ DEFAULTS: dict[str, Any] = {
128
128
  "apify_poll_interval_seconds": 10,
129
129
  "apify_run_timeout_seconds": 900,
130
130
  "llm_provider": DEFAULT_LLM_PROVIDER,
131
+ "llm_provider_cards": [{
132
+ "id": "llm-openai-default",
133
+ "provider": "openai",
134
+ "api_key": "",
135
+ "model": "",
136
+ "base_url": "https://integrate.api.nvidia.com/v1",
137
+ "enabled": True,
138
+ "telegram_llm": False,
139
+ }],
140
+ "llm_active_card_id": "llm-openai-default",
141
+ "llm_telegram_card_id": "",
131
142
  "moonshot_api_key": "",
132
143
  "moonshot_base_url": "",
133
144
  "moonshot_model_name": "",
@@ -293,15 +304,27 @@ def seed_prompt_masters() -> None:
293
304
 
294
305
 
295
306
  def _migrate_settings(settings: Any) -> tuple[dict[str, Any], bool]:
296
- """Keep the shipped OpenAI/NVIDIA NIM default across package upgrades."""
307
+ """Keep the OpenAI/NVIDIA NIM default and materialise the LLM card schema."""
297
308
  if not isinstance(settings, dict):
298
309
  return {"llm_provider": DEFAULT_LLM_PROVIDER}, True
299
310
 
300
- provider = str(settings.get("llm_provider") or "").strip().lower()
301
- if provider not in LEGACY_DEFAULT_LLM_PROVIDERS:
302
- return settings, False
303
-
304
- return {**settings, "llm_provider": DEFAULT_LLM_PROVIDER}, True
311
+ migrated = dict(settings)
312
+ provider = str(migrated.get("llm_provider") or "").strip().lower()
313
+ changed = False
314
+ if provider in LEGACY_DEFAULT_LLM_PROVIDERS:
315
+ migrated["llm_provider"] = DEFAULT_LLM_PROVIDER
316
+ changed = True
317
+
318
+ # Import localmente para evitar que o módulo de catálogo dependa do storage.
319
+ # Settings antigos com um provider explícito continuam a ser devolvidos sem
320
+ # alteração textual; os consumidores/UI fazem a materialização preguiçosa dos
321
+ # cartões, evitando alterar dados durante uma simples leitura de compatibilidade.
322
+ if "llm_provider_cards" not in migrated and provider not in LEGACY_DEFAULT_LLM_PROVIDERS:
323
+ return settings, changed
324
+ from hermes_ui.llm_providers import ensure_llm_provider_cards
325
+
326
+ migrated, cards_changed = ensure_llm_provider_cards(migrated)
327
+ return migrated, changed or cards_changed
305
328
 
306
329
 
307
330
  def ensure_storage() -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
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",