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:
1 parent
d096b5f5d9
commit
7220747a1d
9 files changed
+800
-6
No files matched your search
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user