feat: custom endpoint fields in new profile form (fixes #170, closes #214)

* feat: add custom endpoint fields to new profile form

* fix: skip config write tests when PyYAML not installed

The 4 unit tests for _write_endpoint_to_config imported yaml directly
without handling ImportError. Added pytest.importorskip('yaml') at
module level so the entire test class skips cleanly in environments
without PyYAML. Removed redundant per-method yaml imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: wire frontend for custom endpoint fields in new profile form

- Add Base URL and API key inputs to the profile create form (index.html)
- Wire panels.js submitProfileCreate() to send base_url and api_key
- Clear new fields on form toggle/cancel
- Add client-side URL format validation (must start with http:// or https://)
- Add server-side URL format validation in routes.py (400 for invalid scheme)
- Add test_api_route_rejects_invalid_base_url() covering the new validation
- Base URL input has placeholder 'http://localhost:11434' per review suggestion

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nesquena-hermes
2026-04-10 11:43:49 -07:00
committed by GitHub
parent 1e27940535
commit da160d675f
5 changed files with 209 additions and 3 deletions

View File

@@ -294,8 +294,38 @@ def _create_profile_fallback(name: str, clone_from: str = None,
return profile_dir
def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key: str = None) -> None:
"""Write custom endpoint fields into config.yaml for a profile."""
if not base_url and not api_key:
return
config_path = profile_dir / 'config.yaml'
try:
import yaml as _yaml
except ImportError:
return
cfg = {}
if config_path.exists():
try:
loaded = _yaml.safe_load(config_path.read_text())
if isinstance(loaded, dict):
cfg = loaded
except Exception:
pass
model_section = cfg.get('model', {})
if not isinstance(model_section, dict):
model_section = {}
if base_url:
model_section['base_url'] = base_url
if api_key:
model_section['api_key'] = api_key
cfg['model'] = model_section
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True))
def create_profile_api(name: str, clone_from: str = None,
clone_config: bool = False) -> dict:
clone_config: bool = False,
base_url: str = None,
api_key: str = None) -> dict:
"""Create a new profile. Returns the new profile info dict."""
_validate_profile_name(name)
# Defense-in-depth: validate clone_from here too, even though routes.py
@@ -315,11 +345,26 @@ def create_profile_api(name: str, clone_from: str = None,
except ImportError:
_create_profile_fallback(name, clone_from, clone_config)
# Resolve the profile directory from the profile list when possible.
# hermes_cli and the webui runtime do not always agree on the exact root,
# so we prefer the path returned by list_profiles_api() and fall back to the
# standard profile location only if the profile cannot be found there yet.
profile_path = _DEFAULT_HERMES_HOME / 'profiles' / name
for p in list_profiles_api():
if p['name'] == name:
try:
profile_path = Path(p.get('path') or profile_path)
except Exception:
pass
break
profile_path.mkdir(parents=True, exist_ok=True)
_write_endpoint_to_config(profile_path, base_url=base_url, api_key=api_key)
# Find and return the newly created profile info.
# When hermes_cli is not importable, list_profiles_api() also falls back
# to the stub default-only list and won't find the new profile by name.
# In that case, return a complete profile dict directly.
profile_path = _DEFAULT_HERMES_HOME / 'profiles' / name
for p in list_profiles_api():
if p['name'] == name:
return p

View File

@@ -607,12 +607,18 @@ def handle_post(handler, parsed) -> bool:
clone_from = str(clone_from).strip()
if not _re.match(r'^[a-z0-9][a-z0-9_-]{0,63}$', clone_from):
return bad(handler, 'Invalid clone_from name')
base_url = body.get('base_url', '').strip() if body.get('base_url') else None
api_key = body.get('api_key', '').strip() if body.get('api_key') else None
if base_url and not base_url.startswith(('http://', 'https://')):
return bad(handler, 'base_url must start with http:// or https://')
try:
from api.profiles import create_profile_api
result = create_profile_api(
name,
clone_from=clone_from,
clone_config=bool(body.get('clone_config', False)),
base_url=base_url,
api_key=api_key,
)
return j(handler, {'ok': True, 'profile': result})
except (ValueError, FileExistsError, RuntimeError) as e: