diff --git a/api/onboarding.py b/api/onboarding.py index ecaf1fbc..0c58c0b9 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -17,6 +17,7 @@ from api.config import ( DEFAULT_WORKSPACE, _FALLBACK_MODELS, _HERMES_FOUND, + invalidate_models_cache, _PROVIDER_DISPLAY, _PROVIDER_MODELS, _get_config_path, @@ -1044,6 +1045,80 @@ def apply_onboarding_setup(body: dict) -> dict: return get_onboarding_status() +def apply_self_hosted_provider_setup(body: dict) -> dict: + provider = str(body.get("provider") or "").strip().lower() + model = str(body.get("model") or "").strip() + api_key = str(body.get("api_key") or "").strip() + base_url = _normalize_base_url(str(body.get("base_url") or "")) + activate = body.get("activate") + do_activate = activate is None or bool(activate) + + if provider not in {"ollama", "lmstudio"}: + raise ValueError(f"unsupported self-hosted provider: {provider}") + if not model: + raise ValueError("model is required") + + provider_meta = _SUPPORTED_PROVIDER_SETUPS.get(provider, {}) + if provider_meta.get("requires_base_url"): + if not base_url: + raise ValueError("base_url is required for this provider") + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https"}: + raise ValueError("base_url must start with http:// or https://") + + config_path = _get_config_path() + cfg = _load_yaml_config(config_path) + providers_cfg = cfg.setdefault("providers", {}) + if not isinstance(providers_cfg, dict): + providers_cfg = {} + cfg["providers"] = providers_cfg + + provider_cfg = providers_cfg.setdefault(provider, {}) + if not isinstance(provider_cfg, dict): + provider_cfg = {} + providers_cfg[provider] = provider_cfg + + provider_cfg["base_url"] = base_url + + model_cfg = cfg.get("model", {}) + if not isinstance(model_cfg, dict): + model_cfg = {} + original_model_cfg = dict(model_cfg) + env_var = provider_meta.get("env_var") + + if do_activate: + model_cfg["provider"] = provider + model_cfg["default"] = _normalize_model_for_provider(provider, model) + model_cfg["base_url"] = base_url + cfg["model"] = model_cfg + elif "model" in cfg: + cfg["model"] = original_model_cfg + _save_yaml_config(config_path, cfg) + + if api_key and env_var: + _write_env_file(_get_active_hermes_home() / ".env", {env_var: api_key}) + os.environ[env_var] = api_key + + try: + from api.profiles import _reload_dotenv + _reload_dotenv(_get_active_hermes_home()) + except Exception: + logger.debug("Failed to reload dotenv") + + try: + # hermes_cli may cache config at import time; ask it to reload if possible. + from hermes_cli.config import reload as _cli_reload + _cli_reload() + except Exception: + logger.debug("Failed to reload hermes_cli config") + + invalidate_models_cache() + result = {"ok": True, "provider": provider, "base_url": base_url} + if do_activate: + result["model"] = model_cfg.get("default") + return result + + def complete_onboarding() -> dict: save_settings({"onboarding_completed": True}) return get_onboarding_status() diff --git a/api/providers.py b/api/providers.py index f1dc092c..8ab1bf8f 100644 --- a/api/providers.py +++ b/api/providers.py @@ -745,6 +745,8 @@ _PROVIDER_ENV_VAR_ALIASES: dict[str, tuple[str, ...]] = { "opencode-go": ("OPENCODE_API_KEY",), } +_SELF_HOSTED_PROVIDER_IDS = frozenset({"ollama", "lmstudio"}) + def _provider_credential_env_vars() -> tuple[str, ...]: names = {name for name in _PROVIDER_ENV_VAR.values() if name} @@ -2584,12 +2586,20 @@ def get_providers() -> dict[str, Any]: if pid != "nous": models_total = len(models) + is_self_hosted = pid in _SELF_HOSTED_PROVIDER_IDS + try: + from api.config import _get_provider_base_url + provider_base_url = _get_provider_base_url(pid) if is_self_hosted else None + except Exception: + provider_base_url = None _is_plugin = is_plugin_model_provider(pid) providers.append({ "id": pid, "display_name": display_name, "has_key": has_key, "configurable": not is_oauth and bool(_provider_env_var_for(pid)), + "is_self_hosted": is_self_hosted, + "base_url": provider_base_url, "is_plugin_provider": _is_plugin, "is_oauth": is_oauth, "key_source": key_source, diff --git a/api/routes.py b/api/routes.py index 5d39c95f..db7fed77 100644 --- a/api/routes.py +++ b/api/routes.py @@ -13194,6 +13194,13 @@ def handle_post(handler, parsed) -> bool: return bad(handler, result.get("error", "Unknown error")) return j(handler, result) + if parsed.path == "/api/providers/self-hosted": + try: + from api.onboarding import apply_self_hosted_provider_setup + return j(handler, apply_self_hosted_provider_setup(body)) + except ValueError as exc: + return bad(handler, str(exc), 400) + if parsed.path == "/api/models/refresh": provider_id = (body.get("provider") or "").strip().lower() if not provider_id: diff --git a/static/i18n.js b/static/i18n.js index 2d95a48d..4d9c0092 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1196,6 +1196,7 @@ const LOCALES = { providers_status_not_configured: 'No API key', providers_status_oauth: 'OAuth', providers_status_api_key: 'API key', + providers_status_model: 'Model', providers_status_not_configured_label: 'Not configured', providers_oauth_hint: 'Authenticated via OAuth. No API key needed.', providers_oauth_config_yaml_hint: 'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.', @@ -2853,6 +2854,7 @@ const LOCALES = { providers_status_not_configured: 'Nessuna chiave API', providers_status_oauth: 'OAuth', providers_status_api_key: 'Chiave API', + providers_status_model: 'Modello', providers_status_not_configured_label: 'Non configurato', providers_oauth_hint: 'Autenticato via OAuth. Nessuna chiave API necessaria.', providers_oauth_config_yaml_hint: 'Token configurato via config.yaml. Per aggiornare, modifica la sezione providers in config.yaml o esegui hermes auth.', @@ -4518,6 +4520,7 @@ const LOCALES = { providers_status_not_configured: 'APIキーなし', providers_status_oauth: 'OAuth(認証済み)', providers_status_api_key: 'APIキー', + providers_status_model: 'モデル', providers_status_not_configured_label: '未設定', providers_oauth_hint: 'OAuth 認証済み。APIキーは不要です。', providers_oauth_config_yaml_hint: 'config.yaml でトークンが設定されています。更新するには config.yaml の providers セクションを編集するか hermes auth を実行してください。', @@ -5811,6 +5814,7 @@ const LOCALES = { providers_status_not_configured: 'No API key', providers_status_oauth: 'OAuth', providers_status_api_key: 'API key', + providers_status_model: 'Model', providers_status_not_configured_label: 'Not configured', providers_oauth_hint: 'Authenticated via OAuth. No API key needed.', providers_oauth_config_yaml_hint: 'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.', @@ -7409,6 +7413,7 @@ const LOCALES = { providers_status_not_configured: 'No API key', providers_status_oauth: 'OAuth', providers_status_api_key: 'API key', + providers_status_model: 'Model', providers_status_not_configured_label: 'Not configured', providers_oauth_hint: 'Authenticated via OAuth. No API key needed.', providers_oauth_config_yaml_hint: 'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.', @@ -10587,6 +10592,7 @@ const LOCALES = { providers_status_not_configured: '无 API 密钥', providers_status_oauth: 'OAuth', providers_status_api_key: 'API 密钥', + providers_status_model: '模型', providers_status_not_configured_label: '未配置', providers_oauth_hint: '通过 OAuth 认证。无需 API 密钥。', providers_oauth_config_yaml_hint: '通过 config.yaml 配置的令牌。如需更新,请编辑 config.yaml 中的 providers 部分或运行 hermes auth。', @@ -12572,6 +12578,7 @@ const LOCALES = { providers_status_not_configured: '無 API 金鑰', providers_status_oauth: 'OAuth', providers_status_api_key: 'API 金鑰', + providers_status_model: '模型', providers_status_not_configured_label: '未設定', providers_oauth_hint: '已透過 OAuth 驗證。無需 API 金鑰。', providers_oauth_config_yaml_hint: '已透過 config.yaml 設定權證。要更新,請編輯 config.yaml 中的 providers 區段,或執行 hermes auth。', @@ -14053,6 +14060,7 @@ const LOCALES = { providers_status_not_configured: 'Sem API key', providers_status_oauth: 'OAuth', providers_status_api_key: 'API key', + providers_status_model: 'Modelo', providers_status_not_configured_label: 'Não configurado', providers_oauth_hint: 'Autenticado via OAuth. Sem API key necessária.', providers_oauth_config_yaml_hint: 'Token configurado via config.yaml. Para atualizar, edite config.yaml ou rode hermes auth.', @@ -15611,6 +15619,7 @@ const LOCALES = { providers_status_not_configured: 'No API key', providers_status_oauth: 'OAuth', providers_status_api_key: 'API key', + providers_status_model: 'Model', providers_status_not_configured_label: 'Not configured', providers_oauth_hint: 'Authenticated via OAuth. No API key needed.', providers_oauth_config_yaml_hint: 'Token configured via config.yaml. To update, edit the providers section in your config.yaml or run hermes auth.', @@ -17350,6 +17359,7 @@ const LOCALES = { providers_status_not_configured: 'Aucune clé API', providers_status_oauth: 'OAuth', providers_status_api_key: 'Clé API', + providers_status_model: 'Modèle', providers_status_not_configured_label: 'Non configuré', providers_oauth_hint: 'Authentifié via OAuth. Aucune clé API nécessaire.', providers_oauth_config_yaml_hint: 'Jeton configuré via config.yaml. Pour mettre à jour, modifiez la section des fournisseurs dans votre config.yaml ou exécutez hermes auth.', @@ -18909,6 +18919,7 @@ const LOCALES = { providers_status_not_configured: 'API anahtarı yok', providers_status_oauth: 'OAuth', providers_status_api_key: 'API anahtarı', + providers_status_model: 'Model', providers_status_not_configured_label: 'Yapılandırılmadı', providers_oauth_hint: 'OAuth aracılığıyla kimlik doğrulaması yapıldı. API anahtarına gerek yok.', providers_oauth_config_yaml_hint: 'Belirteç config.yaml aracılığıyla yapılandırıldı. Güncellemek için config.yaml dosyanızdaki sağlayıcılar bölümünü düzenleyin veya hermes auth\'u çalıştırın.', @@ -20654,6 +20665,7 @@ const LOCALES = { providers_status_not_configured: 'Brak klucza API', providers_status_oauth: 'OAuth', providers_status_api_key: 'Klucz API', + providers_status_model: 'Model', providers_status_not_configured_label: 'Nieskonfigurowany', providers_oauth_hint: 'Uwierzytelniono przez OAuth. Klucz API nie jest wymagany.', providers_oauth_config_yaml_hint: 'Token skonfigurowany w config.yaml. Aby zaktualizować, edytuj sekcję providers w config.yaml lub uruchom hermes auth.', @@ -22153,6 +22165,7 @@ const LOCALES = { providers_status_not_configured: 'Chưa có API key', providers_status_oauth: 'OAuth', providers_status_api_key: 'API key', + providers_status_model: 'Mô hình', providers_status_not_configured_label: 'Chưa cấu hình', providers_oauth_hint: 'Đã xác thực bằng OAuth. Không cần API key.', providers_oauth_config_yaml_hint: 'Token được cấu hình qua config.yaml. Để cập nhật, chỉnh sửa phần providers trong config.yaml hoặc chạy hermes auth.', diff --git a/static/panels.js b/static/panels.js index 19740efa..ef8d2885 100644 --- a/static/panels.js +++ b/static/panels.js @@ -9807,7 +9807,11 @@ async function _loadPluginPage(path, label) { // ── Providers panel ───────────────────────────────────────────────────────── -const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn} +const _providerCardEls = new Map(); // providerId → entry used by save/remove/test handlers +const _SELF_HOSTED_DEFAULT_BASE_URLS = Object.freeze({ + ollama: 'http://localhost:11434/v1', + lmstudio: 'http://localhost:1234/v1', +}); async function _fetchProviderQuotaStatus(force=false){ const endpoint=force?`/api/provider/quota?refresh=1&ts=${Date.now()}`:'/api/provider/quota'; @@ -9823,7 +9827,7 @@ async function loadProvidersPanel(){ try{ const data=await api('/api/providers'); const quota=await _fetchProviderQuotaStatus(false).catch(e=>({ok:false,status:'unavailable',quota:null,message:e.message||t('provider_quota_unavailable'),client_fetched_at:new Date().toISOString()})); - const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider); + const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider||p.is_self_hosted); list.innerHTML=''; _providerCardEls.clear(); const quotaCard=_buildProviderQuotaCard(quota); @@ -10299,7 +10303,140 @@ function _buildProviderCard(p){ } let input=null; + let focusInput=null; let saveBtn=null; + if(p.is_self_hosted){ + const defaultBaseUrl=_SELF_HOSTED_DEFAULT_BASE_URLS[p.id]; + const baseUrlField=document.createElement('div'); + baseUrlField.className='provider-card-field'; + const baseUrlLabel=document.createElement('label'); + baseUrlLabel.className='provider-card-label'; + baseUrlLabel.textContent='Base URL'; + baseUrlField.appendChild(baseUrlLabel); + const baseUrlRow=document.createElement('div'); + baseUrlRow.className='provider-card-row'; + const baseUrlInput=document.createElement('input'); + baseUrlInput.type='text'; + baseUrlInput.className='provider-card-input'; + baseUrlInput.placeholder=defaultBaseUrl||'http://localhost:11434/v1'; + baseUrlInput.value=(p.base_url||'').trim()||defaultBaseUrl||''; + baseUrlInput.autocomplete='off'; + const testBtn=document.createElement('button'); + testBtn.type='button'; + testBtn.className='provider-card-btn provider-card-btn-ghost'; + testBtn.textContent='Test connection'; + const probeStatus=document.createElement('div'); + probeStatus.className='provider-card-hint'; + baseUrlRow.appendChild(baseUrlInput); + baseUrlRow.appendChild(testBtn); + baseUrlField.appendChild(baseUrlRow); + baseUrlField.appendChild(probeStatus); + + const keyField=document.createElement('div'); + keyField.className='provider-card-field'; + const keyLabel=document.createElement('label'); + keyLabel.className='provider-card-label'; + keyLabel.textContent='API key (optional)'; + keyField.appendChild(keyLabel); + const keyRow=document.createElement('div'); + keyRow.className='provider-card-row'; + const keyInput=document.createElement('input'); + keyInput.type='password'; + keyInput.className='provider-card-input'; + keyInput.autocomplete='off'; + keyInput.placeholder='Optional'; + keyRow.appendChild(keyInput); + keyField.appendChild(keyRow); + + const modelField=document.createElement('div'); + modelField.className='provider-card-field'; + const modelLabel=document.createElement('label'); + modelLabel.className='provider-card-label'; + modelLabel.textContent=t('providers_status_model')||'Model'; + modelField.appendChild(modelLabel); + const modelRow=document.createElement('div'); + modelRow.className='provider-card-row'; + const modelInput=document.createElement('input'); + modelInput.type='text'; + modelInput.className='provider-card-input'; + modelInput.autocomplete='off'; + modelInput.placeholder='model id'; + const modelDatalist=document.createElement('datalist'); + const modelListId='providerModelList-'+p.id; + modelDatalist.id=modelListId; + modelInput.setAttribute('list',modelListId); + const setModelChoices=(models)=>{ + modelDatalist.innerHTML=''; + const choices=Array.isArray(models)?models:[]; + for(const model of choices){ + const modelId=model&&model.id?model.id:model; + const option=document.createElement('option'); + option.value=modelId; + modelDatalist.appendChild(option); + } + }; + const initialModelChoices=Array.isArray(p.models)?p.models:[]; + setModelChoices(initialModelChoices); + + const saveRow=document.createElement('div'); + saveRow.className='provider-card-row'; + saveRow.style.marginTop='6px'; + saveBtn=document.createElement('button'); + saveBtn.type='button'; + saveBtn.className='provider-card-btn provider-card-btn-primary'; + saveBtn.textContent=t('providers_save'); + saveBtn.onclick=()=>_saveSelfHostedProvider(p.id); + saveBtn.disabled=true; + saveRow.appendChild(saveBtn); + if(p.has_key){ + const removeBtn=document.createElement('button'); + removeBtn.type='button'; + removeBtn.className='provider-card-btn provider-card-btn-danger'; + removeBtn.textContent=t('providers_remove'); + removeBtn.onclick=()=>_removeProviderKey(p.id); + saveRow.appendChild(removeBtn); + } + modelRow.appendChild(modelInput); + modelField.appendChild(modelRow); + body.appendChild(baseUrlField); + body.appendChild(keyField); + body.appendChild(modelField); + body.appendChild(saveRow); + body.appendChild(modelDatalist); + card.appendChild(body); + + const checkSaveEnabled=()=>{ + const hasUrl=baseUrlInput.value.trim().length>0; + const hasModel=modelInput.value.trim().length>0; + saveBtn.disabled=!(hasUrl&&hasModel); + }; + baseUrlInput.addEventListener('input',checkSaveEnabled); + modelInput.addEventListener('input',checkSaveEnabled); + checkSaveEnabled(); + + _providerCardEls.set(p.id,{ + card, + baseUrlInput, + apiKeyInput:keyInput, + modelInput, + modelDatalist, + saveBtn, + testBtn, + probeStatus, + isSelfHosted:true, + setModelChoices, + updateSaveState:checkSaveEnabled, + }); + focusInput=modelInput; + testBtn.onclick=()=>_testSelfHostedConnection(p.id); + header.addEventListener('click',e=>{ + if(e.target.closest('.provider-card-body')) return; + card.classList.toggle('open'); + if(card.classList.contains('open')) setTimeout(()=>focusInput&&focusInput.focus(),0); + }); + return card; + } + if(p.configurable){ const field=document.createElement('div'); field.className='provider-card-field'; @@ -10343,6 +10480,8 @@ function _buildProviderCard(p){ } field.appendChild(row); body.appendChild(field); + focusInput=input; + }else{ const hint=document.createElement('div'); hint.className='provider-card-hint'; @@ -10412,7 +10551,7 @@ function _buildProviderCard(p){ // Don't toggle when clicking inside body (defensive; body isn't inside header) if(e.target.closest('.provider-card-body')) return; card.classList.toggle('open'); - if(card.classList.contains('open')) setTimeout(()=>input.focus(),0); + if(card.classList.contains('open')) setTimeout(()=>focusInput&&focusInput.focus(),0); }); return card; } @@ -10487,6 +10626,108 @@ async function _removeProviderKey(providerId){ } } +async function _testSelfHostedConnection(providerId){ + const els=_providerCardEls.get(providerId); + if(!els||!els.isSelfHosted) return; + const baseUrl=(els.baseUrlInput.value||'').trim(); + const apiKey=(els.apiKeyInput.value||'').trim(); + if(!baseUrl){ + showToast('Base URL is required'); + return; + } + + const testBtn=els.testBtn; + if(!testBtn) return; + const prevLabel=testBtn.textContent; + testBtn.disabled=true; + testBtn.textContent='Testing...'; + if(els.probeStatus){ + els.probeStatus.style.color='var(--muted)'; + els.probeStatus.textContent='Testing connection...'; + } + + try{ + const res=await api('/api/onboarding/probe',{ + method:'POST', + body:JSON.stringify({provider:providerId,base_url:baseUrl,api_key:apiKey||undefined}), + }); + if(res&&res.ok){ + const models=Array.isArray(res.models)?res.models:[]; + if(els.setModelChoices){ + els.setModelChoices(models); + } + if(els.probeStatus){ + const count=models.length; + els.probeStatus.style.color='var(--ok)'; + els.probeStatus.textContent=`Connected. ${count} model(s) available.`; + } + if(!els.modelInput.value&&models.length&&models[0]){ + els.modelInput.value=models[0].id||models[0]; + } + if(els.updateSaveState){ + els.updateSaveState(); + } + }else{ + const err=(res&&res.error)||'unreachable'; + const detail=(res&&res.detail)?` (${res.detail})`:''; + if(els.probeStatus){ + els.probeStatus.style.color='var(--accent)'; + els.probeStatus.textContent=`${err}${detail}`; + } + showToast(`Connection test failed: ${err}`); + } + }catch(e){ + if(els.probeStatus){ + els.probeStatus.style.color='var(--accent)'; + els.probeStatus.textContent=e&&e.message?e.message:'Connection test failed'; + } + showToast('Connection test failed: '+(e&&e.message||'request error')); + }finally{ + testBtn.disabled=false; + testBtn.textContent=prevLabel; + } +} + +async function _saveSelfHostedProvider(providerId){ + const els=_providerCardEls.get(providerId); + if(!els||!els.isSelfHosted) return; + const baseUrl=(els.baseUrlInput.value||'').trim(); + const key=(els.apiKeyInput.value||'').trim(); + const model=(els.modelInput.value||'').trim(); + if(!baseUrl){ + showToast('Base URL is required'); + return; + } + if(!model){ + showToast('Model is required'); + return; + } + if(!els.saveBtn) return; + const saveBtn=els.saveBtn; + const prevLabel=saveBtn.textContent; + saveBtn.disabled=true; + saveBtn.textContent='Saving...'; + try{ + const payload={provider:providerId,base_url:baseUrl,model:model}; + if(key) payload.api_key=key; + const res=await api('/api/providers/self-hosted',{method:'POST',body:JSON.stringify(payload)}); + if(res&&res.ok){ + showToast(`${res.provider} configured`); + if(els.apiKeyInput) els.apiKeyInput.value=''; + _refreshModelDropdownsAfterProviderChange(); + await loadProvidersPanel(); + }else{ + showToast(res&&res.error||'Failed to save provider'); + saveBtn.disabled=false; + saveBtn.textContent=prevLabel; + } + }catch(e){ + showToast('Error: '+(e&&e.message||'Failed to save provider')); + saveBtn.disabled=false; + saveBtn.textContent=prevLabel; + } +} + // Shared dropdown-cache flush invoked after a provider add/remove. The // server-side TTL cache is already invalidated by /api/providers and // /api/providers/delete (via api/providers.py:set_provider_key); this diff --git a/tests/test_custom_providers_in_panel.py b/tests/test_custom_providers_in_panel.py index 600b5b38..976a2b0b 100644 --- a/tests/test_custom_providers_in_panel.py +++ b/tests/test_custom_providers_in_panel.py @@ -107,7 +107,7 @@ class TestCustomProvidersInGetProviders: def test_providers_panel_renders_config_yaml_custom_providers(self): """Settings → Providers must not filter out read-only custom providers.""" src = open("static/panels.js", encoding="utf-8").read() - assert "filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider)" in src + assert "filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider||p.is_self_hosted)" in src assert "Custom provider loaded from config.yaml / hermes model" in src assert "if(p.configurable){" in src diff --git a/tests/test_issue1202_oauth_provider_status.py b/tests/test_issue1202_oauth_provider_status.py index 6e952f99..105c3405 100644 --- a/tests/test_issue1202_oauth_provider_status.py +++ b/tests/test_issue1202_oauth_provider_status.py @@ -297,7 +297,7 @@ class TestProviderListFilter: # Current filter also includes is_custom and is_plugin_provider; require # the full expression so partial regressions are caught. assert ( - "filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider)" + "filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider||p.is_self_hosted)" in self.JS ), ( "Settings → Providers filter must keep OAuth, custom, and plugin " diff --git a/tests/test_issue3260_self_hosted_providers_settings.py b/tests/test_issue3260_self_hosted_providers_settings.py new file mode 100644 index 00000000..bc0e4cf0 --- /dev/null +++ b/tests/test_issue3260_self_hosted_providers_settings.py @@ -0,0 +1,448 @@ +"""Regression coverage for self-hosted provider setup in Settings after onboarding (#3260).""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import api.config as config +import api.onboarding as onboarding +import api.profiles as profiles +import pytest + +from tests.js_source_extract import extract_function +from tests.test_provider_management import _install_fake_hermes_cli, _post + + +ROOT = Path(__file__).resolve().parents[1] +PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + + +_DRIVER = r""" +const fs = require('fs'); +const scenario = JSON.parse(process.argv[2] || '{}'); +const fnSource = fs.readFileSync(process.argv[3], 'utf8'); +const calls = []; +const toasts = []; +let refreshCount = 0; +let reloadCount = 0; + +globalThis._providerCardEls = new Map(); +const saveBtn = { disabled: false, textContent: 'Save' }; +const baseUrlInput = { value: scenario.baseUrl }; +const apiKeyInput = { value: scenario.apiKey || '' }; +const modelInput = { value: scenario.model }; + +globalThis._providerCardEls.set(scenario.providerId, { + isSelfHosted: true, + baseUrlInput, + apiKeyInput, + modelInput, + saveBtn, +}); + +globalThis.api = async (url, opts) => { + calls.push({ + url, + method: opts.method, + body: JSON.parse(opts.body), + }); + return { ok: true, provider: scenario.providerId }; +}; +globalThis.showToast = (msg) => toasts.push(msg); +globalThis._refreshModelDropdownsAfterProviderChange = () => { refreshCount += 1; }; +globalThis.loadProvidersPanel = async () => { reloadCount += 1; }; + +eval(fnSource); + +(async () => { + await _saveSelfHostedProvider(scenario.providerId); + process.stdout.write(JSON.stringify({ + calls, + toasts, + refreshCount, + reloadCount, + apiKeyAfter: apiKeyInput.value, + })); +})().catch((error) => { + process.stderr.write(String(error && error.stack || error)); + process.exit(1); +}); +""" + + +_PROBE_DRIVER = r""" +const fs = require('fs'); +const scenario = JSON.parse(process.argv[2] || '{}'); +const fnSource = fs.readFileSync(process.argv[3], 'utf8'); +const calls = []; +const toasts = []; +const saveBtn = { disabled: true, textContent: 'Save' }; +const testBtn = { disabled: false, textContent: 'Test connection' }; +const probeStatus = { style: {}, textContent: '' }; +const baseUrlInput = { value: scenario.baseUrl }; +const apiKeyInput = { value: scenario.apiKey || '' }; +const modelInput = { value: scenario.model || '' }; + +globalThis._providerCardEls = new Map(); +globalThis._providerCardEls.set(scenario.providerId, { + isSelfHosted: true, + baseUrlInput, + apiKeyInput, + modelInput, + saveBtn, + testBtn, + probeStatus, + setModelChoices(models) { + this.modelChoices = models; + }, + updateSaveState() { + saveBtn.disabled = !(baseUrlInput.value.trim() && modelInput.value.trim()); + }, +}); + +globalThis.api = async (url, opts) => { + calls.push({ + url, + method: opts.method, + body: JSON.parse(opts.body), + }); + const discoveredModels = Array.isArray(scenario.discoveredModels) + ? scenario.discoveredModels + : [{ id: scenario.discoveredModel }]; + return { + ok: true, + models: discoveredModels, + }; +}; +globalThis.showToast = (msg) => toasts.push(msg); + +eval(fnSource); + +(async () => { + await _testSelfHostedConnection(scenario.providerId); + process.stdout.write(JSON.stringify({ + calls, + toasts, + saveDisabled: saveBtn.disabled, + modelValue: modelInput.value, + probeText: probeStatus.textContent, + testDisabled: testBtn.disabled, + })); +})().catch((error) => { + process.stderr.write(String(error && error.stack || error)); + process.exit(1); +}); +""" + + +@pytest.fixture +def isolated_self_hosted_env(monkeypatch, tmp_path): + _install_fake_hermes_cli(monkeypatch) + fake_config_path = tmp_path / "config.yaml" + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + config.cfg.clear() + config.cfg["model"] = {} + config.cfg["providers"] = {} + config._cfg_mtime = 0.0 + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.setattr(onboarding, "_get_active_hermes_home", lambda: tmp_path) + monkeypatch.setattr(onboarding, "_get_config_path", lambda: fake_config_path) + monkeypatch.setattr(config, "_get_config_path", lambda: fake_config_path) + yield tmp_path, fake_config_path + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + + +def test_post_self_hosted_provider_succeeds_for_ollama(isolated_self_hosted_env): + _tmp_path, _fake_config_path = isolated_self_hosted_env + body, status = _post("/api/providers/self-hosted", { + "provider": "ollama", + "model": "qwen3:8b", + "base_url": "http://127.0.0.1:11434/v1", + }) + assert status == 200 + assert body["ok"] is True + assert body["provider"] == "ollama" + + +def test_post_self_hosted_provider_succeeds_for_lmstudio(isolated_self_hosted_env): + _tmp_path, _fake_config_path = isolated_self_hosted_env + body, status = _post("/api/providers/self-hosted", { + "provider": "lmstudio", + "model": "local-model", + "base_url": "http://127.0.0.1:1234/v1", + }) + assert status == 200 + assert body["ok"] is True + assert body["provider"] == "lmstudio" + + +def test_apply_self_hosted_provider_setup_persists_ollama_base_url_and_active_model(isolated_self_hosted_env, monkeypatch): + _tmp_path, fake_config_path = isolated_self_hosted_env + calls = [] + monkeypatch.setattr(onboarding, "invalidate_models_cache", lambda: calls.append("invalidate")) + body = onboarding.apply_self_hosted_provider_setup({ + "provider": "ollama", + "model": "qwen3:8b", + "base_url": "http://127.0.0.1:11434/v1", + }) + assert body["ok"] is True + assert body["provider"] == "ollama" + cfg = onboarding._load_yaml_config(fake_config_path) + assert cfg["providers"]["ollama"]["base_url"] == "http://127.0.0.1:11434/v1" + assert cfg["model"]["provider"] == "ollama" + assert cfg["model"]["base_url"] == "http://127.0.0.1:11434/v1" + assert cfg["model"]["default"] == onboarding._normalize_model_for_provider("ollama", "qwen3:8b") + assert calls == ["invalidate"] + + +def test_apply_self_hosted_provider_setup_persists_lmstudio_base_url_and_active_model(isolated_self_hosted_env, monkeypatch): + _tmp_path, fake_config_path = isolated_self_hosted_env + calls = [] + monkeypatch.setattr(onboarding, "invalidate_models_cache", lambda: calls.append("invalidate")) + body = onboarding.apply_self_hosted_provider_setup({ + "provider": "lmstudio", + "model": "local-model", + "base_url": "http://127.0.0.1:1234/v1", + }) + assert body["ok"] is True + assert body["provider"] == "lmstudio" + cfg = onboarding._load_yaml_config(fake_config_path) + assert cfg["providers"]["lmstudio"]["base_url"] == "http://127.0.0.1:1234/v1" + assert cfg["model"]["provider"] == "lmstudio" + assert cfg["model"]["base_url"] == "http://127.0.0.1:1234/v1" + assert cfg["model"]["default"] == onboarding._normalize_model_for_provider("lmstudio", "local-model") + assert calls == ["invalidate"] + + +def test_apply_self_hosted_provider_setup_persists_base_url_without_switching_active_provider( + isolated_self_hosted_env, + monkeypatch, +): + _tmp_path, fake_config_path = isolated_self_hosted_env + original_model_cfg = { + "provider": "anthropic", + "default": "claude-sonnet-4-5", + "base_url": "", + } + onboarding._save_yaml_config(fake_config_path, { + "model": { + **original_model_cfg, + "custom_flag": "preserve-me", + }, + "providers": {}, + }) + calls = [] + monkeypatch.setattr(onboarding, "invalidate_models_cache", lambda: calls.append("invalidate")) + body = onboarding.apply_self_hosted_provider_setup({ + "provider": "ollama", + "model": "qwen3:8b", + "base_url": "http://127.0.0.1:11434/v1", + "activate": False, + }) + assert body["ok"] is True + assert body["provider"] == "ollama" + assert "model" not in body + cfg = onboarding._load_yaml_config(fake_config_path) + assert cfg["providers"]["ollama"]["base_url"] == "http://127.0.0.1:11434/v1" + assert cfg["model"] == {**original_model_cfg, "custom_flag": "preserve-me"} + assert calls == ["invalidate"] + + +def test_apply_self_hosted_provider_setup_omits_env_write_for_empty_optional_key( + isolated_self_hosted_env, + monkeypatch, +): + tmp_path, _fake_config_path = isolated_self_hosted_env + calls = [] + monkeypatch.setattr(onboarding, "invalidate_models_cache", lambda: calls.append("invalidate")) + body = onboarding.apply_self_hosted_provider_setup({ + "provider": "lmstudio", + "model": "local-model", + "base_url": "http://127.0.0.1:1234/v1", + "api_key": "", + }) + assert body["ok"] is True + assert not (tmp_path / ".env").exists() + assert calls == ["invalidate"] + + +def test_apply_self_hosted_provider_setup_writes_only_target_provider_key_and_skips_reload_config( + isolated_self_hosted_env, + monkeypatch, +): + tmp_path, _fake_config_path = isolated_self_hosted_env + invalidate_calls = [] + reload_calls = [] + monkeypatch.setattr(onboarding, "invalidate_models_cache", lambda: invalidate_calls.append("invalidate")) + monkeypatch.setattr(onboarding, "reload_config", lambda: reload_calls.append("reload")) + body = onboarding.apply_self_hosted_provider_setup({ + "provider": "lmstudio", + "model": "local-model", + "base_url": "http://127.0.0.1:1234/v1", + "api_key": "lm-key-12345678", + }) + assert body["ok"] is True + env_text = (tmp_path / ".env").read_text(encoding="utf-8") + assert "LM_API_KEY=lm-key-12345678" in env_text + assert "OLLAMA_API_KEY" not in env_text + assert invalidate_calls == ["invalidate"] + assert reload_calls == [] + + +def test_post_self_hosted_provider_rejects_invalid_provider(isolated_self_hosted_env): + body, status = _post("/api/providers/self-hosted", { + "provider": "custom", + "model": "x", + "base_url": "http://127.0.0.1:11434/v1", + }) + assert status == 400 + assert "unsupported self-hosted provider" in body.get("error", "") + + +def test_get_providers_exposes_self_hosted_flags_and_base_url(monkeypatch, tmp_path): + _install_fake_hermes_cli(monkeypatch) + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + config.cfg.clear() + config.cfg["model"] = {} + config.cfg["providers"] = { + "ollama": {"base_url": "http://127.0.0.1:11434/v1"}, + "lmstudio": {"base_url": "http://127.0.0.1:1234/v1"}, + } + try: + config._cfg_mtime = 0.0 + from api.providers import get_providers + + result = get_providers() + by_id = {p["id"]: p for p in result["providers"]} + assert by_id["ollama"]["is_self_hosted"] is True + assert by_id["ollama"]["base_url"] == "http://127.0.0.1:11434/v1" + assert by_id["ollama"]["configurable"] is False + assert by_id["lmstudio"]["is_self_hosted"] is True + assert by_id["lmstudio"]["base_url"] == "http://127.0.0.1:1234/v1" + assert by_id["ollama-cloud"]["is_self_hosted"] is False + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + + +def test_save_self_hosted_provider_posts_expected_payload(tmp_path): + if NODE is None: + pytest.skip("node is required to execute the self-hosted provider harness") + + fn_path = tmp_path / "saveSelfHostedProvider.js" + fn_path.write_text( + extract_function(PANELS_JS, "_saveSelfHostedProvider", prefix="async function"), + encoding="utf-8", + ) + driver_path = tmp_path / "driver.js" + driver_path.write_text(_DRIVER, encoding="utf-8") + scenario = { + "providerId": "ollama", + "baseUrl": "http://127.0.0.1:11434/v1", + "model": "qwen3:8b", + "apiKey": "", + } + result = subprocess.run( + [NODE, str(driver_path), json.dumps(scenario), str(fn_path)], + capture_output=True, + text=True, + check=True, + ) + payload = json.loads(result.stdout) + assert payload["calls"] == [{ + "url": "/api/providers/self-hosted", + "method": "POST", + "body": { + "provider": "ollama", + "base_url": "http://127.0.0.1:11434/v1", + "model": "qwen3:8b", + }, + }] + assert payload["refreshCount"] == 1 + assert payload["reloadCount"] == 1 + assert payload["apiKeyAfter"] == "" + + +def test_self_hosted_provider_card_keeps_remove_key_path(): + card_src = extract_function(PANELS_JS, "_buildProviderCard", prefix="function") + assert "if(p.has_key){" in card_src + assert "removeBtn.onclick=()=>_removeProviderKey(p.id);" in card_src + assert "removeBtn.textContent=t('providers_remove');" in card_src + + +def test_probe_self_hosted_provider_populates_model_and_enables_save(tmp_path): + if NODE is None: + pytest.skip("node is required to execute the self-hosted provider harness") + + fn_path = tmp_path / "testSelfHostedConnection.js" + fn_path.write_text( + extract_function(PANELS_JS, "_testSelfHostedConnection", prefix="async function"), + encoding="utf-8", + ) + driver_path = tmp_path / "probe-driver.js" + driver_path.write_text(_PROBE_DRIVER, encoding="utf-8") + scenario = { + "providerId": "ollama", + "baseUrl": "http://127.0.0.1:11434/v1", + "apiKey": "", + "model": "", + "discoveredModel": "qwen3:8b", + } + result = subprocess.run( + [NODE, str(driver_path), json.dumps(scenario), str(fn_path)], + capture_output=True, + text=True, + check=True, + ) + payload = json.loads(result.stdout) + assert payload["calls"] == [{ + "url": "/api/onboarding/probe", + "method": "POST", + "body": { + "provider": "ollama", + "base_url": "http://127.0.0.1:11434/v1", + }, + }] + assert payload["modelValue"] == "qwen3:8b" + assert payload["saveDisabled"] is False + assert payload["probeText"] == "Connected. 1 model(s) available." + assert payload["testDisabled"] is False + + +def test_probe_self_hosted_provider_accepts_string_models(tmp_path): + if NODE is None: + pytest.skip("node is required to execute the self-hosted provider harness") + + fn_path = tmp_path / "testSelfHostedConnection.js" + fn_path.write_text( + extract_function(PANELS_JS, "_testSelfHostedConnection", prefix="async function"), + encoding="utf-8", + ) + driver_path = tmp_path / "probe-driver-string.js" + driver_path.write_text(_PROBE_DRIVER, encoding="utf-8") + scenario = { + "providerId": "ollama", + "baseUrl": "http://127.0.0.1:11434/v1", + "apiKey": "", + "model": "", + "discoveredModels": ["qwen3:8b"], + } + result = subprocess.run( + [NODE, str(driver_path), json.dumps(scenario), str(fn_path)], + capture_output=True, + text=True, + check=True, + ) + payload = json.loads(result.stdout) + assert payload["modelValue"] == "qwen3:8b" + assert payload["saveDisabled"] is False diff --git a/tests/test_plugin_model_providers.py b/tests/test_plugin_model_providers.py index f9134b51..d89c1820 100644 --- a/tests/test_plugin_model_providers.py +++ b/tests/test_plugin_model_providers.py @@ -184,7 +184,7 @@ class TestPluginModelProvidersPanelFilter: def test_providers_panel_includes_plugin_model_providers(self): src = open("static/panels.js", encoding="utf-8").read() assert "p.is_plugin_provider" in src - assert "filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider)" in src + assert "filter(p=>p.configurable||p.is_oauth||p.is_custom||p.is_plugin_provider||p.is_self_hosted)" in src class TestPluginModelProvidersPicker: