feat(#3260): self-hosted provider setup (Ollama/LM Studio) in Settings (i18n complete)

Clean rebase of rodboev's #5408 (rebase-first).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermesandrodboev committed 2026-07-02 08:17:44 +00:00
1 parent d096b5f5d9
commit 7220747a1d
9 files changed
+800 -6

No files matched your search

+75
View File
@@ -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()
+10
View File
@@ -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,
+7
View File
@@ -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:
+13
View File
@@ -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.',
+244 -3
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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 "
@@ -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
+1 -1
View File
@@ -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: