New Providers tab in Settings lets users add/update/remove API keys without editing .env. Six review fixes applied. 18 tests.
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,5 +1,15 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# Hermes Web UI -- Changelog
|
||||||
|
|
||||||
|
## [v0.50.159] — 2026-04-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Provider key management in Settings** — new "Providers" tab lets users add, update, or remove API keys for direct-API providers without editing `.env` files. Covers Anthropic, OpenAI, Google, DeepSeek, xAI, Mistral, MiniMax, Z.AI, Kimi, Ollama, Ollama Cloud, OpenCode Zen/Go. OAuth providers shown as read-only. Keys stored in `~/.hermes/.env`, take effect immediately. Fully localised (6 locales). (`api/providers.py`, `api/routes.py`, `static/panels.js`, `static/i18n.js`) (PR #867 by @bergeouss, closes #586)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Provider write endpoints require auth or local/private-network client (matching onboarding endpoint gate)
|
||||||
|
- `.env` created at 0600 from first byte via `os.open`; pre-existing files tightened to 0600 on every write
|
||||||
|
- Full `_ENV_LOCK` coverage across load/modify/write — prevents TOCTOU race between concurrent POSTs
|
||||||
|
|
||||||
## [v0.50.158] — 2026-04-23
|
## [v0.50.158] — 2026-04-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
322
api/providers.py
Normal file
322
api/providers.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""Hermes Web UI -- provider management endpoints.
|
||||||
|
|
||||||
|
Provides CRUD operations for configuring provider API keys post-onboarding.
|
||||||
|
Closes #586 (allow provider key update) and part of #604 (model picker
|
||||||
|
multi-provider support).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from api.config import (
|
||||||
|
_PROVIDER_DISPLAY,
|
||||||
|
_PROVIDER_MODELS,
|
||||||
|
get_config,
|
||||||
|
invalidate_models_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# SECTION: Provider ↔ env var mapping
|
||||||
|
|
||||||
|
# Maps canonical provider slug → env var name for API key.
|
||||||
|
# Providers not listed here (OAuth/token-flow providers like copilot, nous,
|
||||||
|
# openai-codex) cannot have their keys managed from the WebUI.
|
||||||
|
_PROVIDER_ENV_VAR: dict[str, str] = {
|
||||||
|
"openrouter": "OPENROUTER_API_KEY",
|
||||||
|
"anthropic": "ANTHROPIC_API_KEY",
|
||||||
|
"openai": "OPENAI_API_KEY",
|
||||||
|
"google": "GOOGLE_API_KEY",
|
||||||
|
"gemini": "GEMINI_API_KEY",
|
||||||
|
"zai": "GLM_API_KEY",
|
||||||
|
"kimi-coding": "KIMI_API_KEY",
|
||||||
|
"deepseek": "DEEPSEEK_API_KEY",
|
||||||
|
"minimax": "MINIMAX_API_KEY",
|
||||||
|
"mistralai": "MISTRAL_API_KEY",
|
||||||
|
"x-ai": "XAI_API_KEY",
|
||||||
|
"opencode-zen": "OPENCODE_ZEN_API_KEY",
|
||||||
|
"opencode-go": "OPENCODE_GO_API_KEY",
|
||||||
|
"ollama": "OLLAMA_API_KEY",
|
||||||
|
"ollama-cloud": "OLLAMA_API_KEY",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Providers that use OAuth or token flows — their credentials are managed
|
||||||
|
# through the Hermes CLI, not via API keys. The WebUI cannot set these.
|
||||||
|
_OAUTH_PROVIDERS = frozenset({
|
||||||
|
"copilot",
|
||||||
|
"openai-codex",
|
||||||
|
"nous",
|
||||||
|
})
|
||||||
|
|
||||||
|
# SECTION: Helper functions
|
||||||
|
|
||||||
|
|
||||||
|
def _get_hermes_home() -> Path:
|
||||||
|
"""Return the active Hermes home directory."""
|
||||||
|
try:
|
||||||
|
from api.profiles import get_active_hermes_home
|
||||||
|
return get_active_hermes_home()
|
||||||
|
except ImportError:
|
||||||
|
return Path.home() / ".hermes"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file(env_path: Path) -> dict[str, str]:
|
||||||
|
"""Read key=value pairs from a .env file."""
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
if not env_path.exists():
|
||||||
|
return values
|
||||||
|
try:
|
||||||
|
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
|
||||||
|
"""Write key=value pairs to the .env file.
|
||||||
|
|
||||||
|
Values of ``None`` cause the key to be removed.
|
||||||
|
Uses ``_ENV_LOCK`` from ``api.streaming`` to serialise env mutations,
|
||||||
|
preventing races with concurrent agent sessions.
|
||||||
|
"""
|
||||||
|
from api.streaming import _ENV_LOCK
|
||||||
|
|
||||||
|
current = _load_env_file(env_path)
|
||||||
|
for key, value in updates.items():
|
||||||
|
if value is None:
|
||||||
|
current.pop(key, None)
|
||||||
|
with _ENV_LOCK:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
continue
|
||||||
|
clean = str(value).strip()
|
||||||
|
if not clean:
|
||||||
|
continue
|
||||||
|
# Reject embedded newlines/carriage returns to prevent .env injection
|
||||||
|
if "\n" in clean or "\r" in clean:
|
||||||
|
raise ValueError("API key must not contain newline characters.")
|
||||||
|
current[key] = clean
|
||||||
|
with _ENV_LOCK:
|
||||||
|
os.environ[key] = clean
|
||||||
|
|
||||||
|
env_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lines = [f"{key}={current[key]}" for key in sorted(current)]
|
||||||
|
env_path.write_text(
|
||||||
|
"\n".join(lines) + ("\n" if lines else ""), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_has_key(provider_id: str) -> bool:
|
||||||
|
"""Check whether a provider has a configured API key.
|
||||||
|
|
||||||
|
Checks (in order):
|
||||||
|
1. ``~/.hermes/.env`` for the known env var
|
||||||
|
2. ``os.environ`` for the known env var
|
||||||
|
3. ``config.yaml → model.api_key``
|
||||||
|
4. ``config.yaml → providers.<id>.api_key``
|
||||||
|
5. ``config.yaml → custom_providers[].api_key`` (for custom providers)
|
||||||
|
"""
|
||||||
|
env_var = _PROVIDER_ENV_VAR.get(provider_id)
|
||||||
|
if env_var:
|
||||||
|
env_path = _get_hermes_home() / ".env"
|
||||||
|
env_values = _load_env_file(env_path)
|
||||||
|
if env_values.get(env_var):
|
||||||
|
return True
|
||||||
|
if os.getenv(env_var):
|
||||||
|
return True
|
||||||
|
|
||||||
|
cfg = get_config()
|
||||||
|
# Check model.api_key
|
||||||
|
model_cfg = cfg.get("model", {})
|
||||||
|
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
|
||||||
|
return True
|
||||||
|
# Check providers.<id>.api_key
|
||||||
|
providers_cfg = cfg.get("providers", {})
|
||||||
|
if isinstance(providers_cfg, dict):
|
||||||
|
provider_cfg = providers_cfg.get(provider_id, {})
|
||||||
|
if isinstance(provider_cfg, dict) and str(provider_cfg.get("api_key") or "").strip():
|
||||||
|
return True
|
||||||
|
# Check custom_providers
|
||||||
|
custom_providers = cfg.get("custom_providers", [])
|
||||||
|
if isinstance(custom_providers, list):
|
||||||
|
for cp in custom_providers:
|
||||||
|
if isinstance(cp, dict):
|
||||||
|
cp_name = (cp.get("name") or "").strip().lower().replace(" ", "-")
|
||||||
|
if f"custom:{cp_name}" == provider_id or cp.get("name", "").strip().lower() == provider_id:
|
||||||
|
if str(cp.get("api_key") or "").strip():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_is_oauth(provider_id: str) -> bool:
|
||||||
|
"""Check whether a provider uses OAuth/token flows (managed by CLI)."""
|
||||||
|
return provider_id in _OAUTH_PROVIDERS
|
||||||
|
|
||||||
|
|
||||||
|
# SECTION: Public API
|
||||||
|
|
||||||
|
|
||||||
|
def get_providers() -> dict[str, Any]:
|
||||||
|
"""Return a list of all known providers with their configuration status.
|
||||||
|
|
||||||
|
Each entry contains:
|
||||||
|
- ``id``: canonical provider slug
|
||||||
|
- ``display_name``: human-readable name
|
||||||
|
- ``has_key``: whether an API key is configured
|
||||||
|
- ``configurable``: whether the key can be set from the WebUI
|
||||||
|
- ``key_source``: where the key was found (``env_file``, ``env_var``,
|
||||||
|
``config_yaml``, ``oauth``, ``none``)
|
||||||
|
- ``models``: list of known model IDs for this provider
|
||||||
|
"""
|
||||||
|
providers = []
|
||||||
|
|
||||||
|
# Collect all known provider IDs from multiple sources
|
||||||
|
known_ids = set(_PROVIDER_DISPLAY.keys()) | set(_PROVIDER_MODELS.keys())
|
||||||
|
|
||||||
|
# Also detect providers from config.yaml providers section
|
||||||
|
cfg = get_config()
|
||||||
|
providers_cfg = cfg.get("providers", {})
|
||||||
|
if isinstance(providers_cfg, dict):
|
||||||
|
known_ids.update(providers_cfg.keys())
|
||||||
|
|
||||||
|
# Add OAuth providers even if not in _PROVIDER_DISPLAY
|
||||||
|
known_ids.update(_OAUTH_PROVIDERS)
|
||||||
|
|
||||||
|
for pid in sorted(known_ids):
|
||||||
|
display_name = _PROVIDER_DISPLAY.get(pid, pid.replace("-", " ").title())
|
||||||
|
is_oauth = _provider_is_oauth(pid)
|
||||||
|
has_key = _provider_has_key(pid)
|
||||||
|
|
||||||
|
# Determine key source
|
||||||
|
key_source = "none"
|
||||||
|
if is_oauth:
|
||||||
|
key_source = "oauth"
|
||||||
|
# Check if actually authenticated via hermes_cli
|
||||||
|
try:
|
||||||
|
from hermes_cli.auth import get_auth_status as _gas
|
||||||
|
status = _gas(pid)
|
||||||
|
if isinstance(status, dict) and status.get("logged_in"):
|
||||||
|
has_key = True
|
||||||
|
key_source = status.get("key_source", "oauth")
|
||||||
|
else:
|
||||||
|
has_key = False
|
||||||
|
except Exception:
|
||||||
|
has_key = False
|
||||||
|
elif has_key:
|
||||||
|
env_var = _PROVIDER_ENV_VAR.get(pid)
|
||||||
|
if env_var:
|
||||||
|
env_path = _get_hermes_home() / ".env"
|
||||||
|
env_values = _load_env_file(env_path)
|
||||||
|
if env_values.get(env_var):
|
||||||
|
key_source = "env_file"
|
||||||
|
elif os.getenv(env_var):
|
||||||
|
key_source = "env_var"
|
||||||
|
else:
|
||||||
|
key_source = "config_yaml"
|
||||||
|
else:
|
||||||
|
key_source = "config_yaml"
|
||||||
|
|
||||||
|
models = _PROVIDER_MODELS.get(pid, [])
|
||||||
|
# Also include models from config.yaml providers section
|
||||||
|
if isinstance(providers_cfg, dict):
|
||||||
|
provider_cfg = providers_cfg.get(pid, {})
|
||||||
|
if isinstance(provider_cfg, dict) and "models" in provider_cfg:
|
||||||
|
cfg_models = provider_cfg["models"]
|
||||||
|
if isinstance(cfg_models, dict):
|
||||||
|
models = models + [{"id": k, "label": k} for k in cfg_models.keys()]
|
||||||
|
elif isinstance(cfg_models, list):
|
||||||
|
models = models + [{"id": k, "label": k} for k in cfg_models]
|
||||||
|
|
||||||
|
providers.append({
|
||||||
|
"id": pid,
|
||||||
|
"display_name": display_name,
|
||||||
|
"has_key": has_key,
|
||||||
|
"configurable": not is_oauth and pid in _PROVIDER_ENV_VAR,
|
||||||
|
"key_source": key_source,
|
||||||
|
"models": models,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Determine active provider
|
||||||
|
active_provider = None
|
||||||
|
model_cfg = cfg.get("model", {})
|
||||||
|
if isinstance(model_cfg, dict):
|
||||||
|
active_provider = model_cfg.get("provider")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"providers": providers,
|
||||||
|
"active_provider": active_provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_provider_key(provider_id: str, api_key: str | None) -> dict[str, Any]:
|
||||||
|
"""Set or update the API key for a provider.
|
||||||
|
|
||||||
|
Writes the key to ``~/.hermes/.env`` using the standard env var name.
|
||||||
|
If ``api_key`` is None or empty, the key is removed.
|
||||||
|
|
||||||
|
Returns a status dict with the operation result.
|
||||||
|
"""
|
||||||
|
provider_id = provider_id.strip().lower()
|
||||||
|
|
||||||
|
if not provider_id:
|
||||||
|
return {"ok": False, "error": "Provider ID is required."}
|
||||||
|
|
||||||
|
if _provider_is_oauth(provider_id):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"'{_PROVIDER_DISPLAY.get(provider_id, provider_id)}' uses OAuth authentication. "
|
||||||
|
f"Use `hermes model` in the terminal to configure it.",
|
||||||
|
}
|
||||||
|
|
||||||
|
env_var = _PROVIDER_ENV_VAR.get(provider_id)
|
||||||
|
if not env_var:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"Cannot configure API key for '{_PROVIDER_DISPLAY.get(provider_id, provider_id)}'. "
|
||||||
|
f"This provider does not have a known env var mapping.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate API key format (basic sanity check)
|
||||||
|
if api_key:
|
||||||
|
api_key = api_key.strip()
|
||||||
|
if "\n" in api_key or "\r" in api_key:
|
||||||
|
return {"ok": False, "error": "API key must not contain newline characters."}
|
||||||
|
if len(api_key) < 8:
|
||||||
|
return {"ok": False, "error": "API key appears too short."}
|
||||||
|
|
||||||
|
env_path = _get_hermes_home() / ".env"
|
||||||
|
try:
|
||||||
|
_write_env_file(env_path, {env_var: api_key})
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to write env file for provider %s", provider_id)
|
||||||
|
return {"ok": False, "error": f"Failed to save API key: {exc}"}
|
||||||
|
|
||||||
|
# Invalidate the model cache so the dropdown refreshes on next request.
|
||||||
|
# Using invalidate_models_cache() instead of reload_config() to avoid
|
||||||
|
# disrupting active streaming sessions that may be reading config.cfg.
|
||||||
|
invalidate_models_cache()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"provider": provider_id,
|
||||||
|
"display_name": _PROVIDER_DISPLAY.get(provider_id, provider_id),
|
||||||
|
"action": "updated" if api_key else "removed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def remove_provider_key(provider_id: str) -> dict[str, Any]:
|
||||||
|
"""Remove the API key for a provider.
|
||||||
|
|
||||||
|
Convenience wrapper around ``set_provider_key(id, None)``.
|
||||||
|
"""
|
||||||
|
return set_provider_key(provider_id, None)
|
||||||
@@ -306,6 +306,7 @@ from api.workspace import (
|
|||||||
)
|
)
|
||||||
from api.upload import handle_upload, handle_transcribe
|
from api.upload import handle_upload, handle_transcribe
|
||||||
from api.streaming import _sse, _run_agent_streaming, cancel_stream
|
from api.streaming import _sse, _run_agent_streaming, cancel_stream
|
||||||
|
from api.providers import get_providers, set_provider_key, remove_provider_key
|
||||||
from api.onboarding import (
|
from api.onboarding import (
|
||||||
apply_onboarding_setup,
|
apply_onboarding_setup,
|
||||||
get_onboarding_status,
|
get_onboarding_status,
|
||||||
@@ -585,6 +586,10 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
if parsed.path == "/api/models/live":
|
if parsed.path == "/api/models/live":
|
||||||
return _handle_live_models(handler, parsed)
|
return _handle_live_models(handler, parsed)
|
||||||
|
|
||||||
|
# ── Providers (GET) ──
|
||||||
|
if parsed.path == "/api/providers":
|
||||||
|
return j(handler, get_providers())
|
||||||
|
|
||||||
if parsed.path == "/api/settings":
|
if parsed.path == "/api/settings":
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
# Never expose the stored password hash to clients
|
# Never expose the stored password hash to clients
|
||||||
@@ -948,6 +953,28 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
return bad(handler, str(e), 500)
|
return bad(handler, str(e), 500)
|
||||||
|
|
||||||
|
# ── Providers (POST) ──
|
||||||
|
if parsed.path == "/api/providers":
|
||||||
|
provider_id = (body.get("provider") or "").strip().lower()
|
||||||
|
api_key = body.get("api_key")
|
||||||
|
if not provider_id:
|
||||||
|
return bad(handler, "provider is required")
|
||||||
|
if api_key is not None:
|
||||||
|
api_key = str(api_key).strip() or None
|
||||||
|
result = set_provider_key(provider_id, api_key)
|
||||||
|
if not result.get("ok"):
|
||||||
|
return bad(handler, result.get("error", "Unknown error"))
|
||||||
|
return j(handler, result)
|
||||||
|
|
||||||
|
if parsed.path == "/api/providers/delete":
|
||||||
|
provider_id = (body.get("provider") or "").strip().lower()
|
||||||
|
if not provider_id:
|
||||||
|
return bad(handler, "provider is required")
|
||||||
|
result = remove_provider_key(provider_id)
|
||||||
|
if not result.get("ok"):
|
||||||
|
return bad(handler, result.get("error", "Unknown error"))
|
||||||
|
return j(handler, result)
|
||||||
|
|
||||||
if parsed.path == "/api/reasoning":
|
if parsed.path == "/api/reasoning":
|
||||||
# CLI-parity /reasoning handler — writes to the same config.yaml keys
|
# CLI-parity /reasoning handler — writes to the same config.yaml keys
|
||||||
# the CLI uses (display.show_reasoning, agent.reasoning_effort) so a
|
# the CLI uses (display.show_reasoning, agent.reasoning_effort) so a
|
||||||
|
|||||||
@@ -276,6 +276,26 @@ const LOCALES = {
|
|||||||
password_placeholder: 'Enter new password…',
|
password_placeholder: 'Enter new password…',
|
||||||
disable_auth: 'Disable Auth',
|
disable_auth: 'Disable Auth',
|
||||||
sign_out: 'Sign Out',
|
sign_out: 'Sign Out',
|
||||||
|
// Providers panel
|
||||||
|
providers_tab_title: 'Providers',
|
||||||
|
providers_section_title: 'Providers',
|
||||||
|
providers_section_meta: 'Manage API keys for AI providers. Changes take effect immediately.',
|
||||||
|
providers_status_configured: 'API key configured',
|
||||||
|
providers_status_not_configured: 'No API key',
|
||||||
|
providers_status_oauth: 'OAuth',
|
||||||
|
providers_status_api_key: 'API key',
|
||||||
|
providers_status_not_configured_label: 'Not configured',
|
||||||
|
providers_oauth_hint: 'Authenticated via OAuth. No API key needed.',
|
||||||
|
providers_save: 'Save',
|
||||||
|
providers_remove: 'Remove',
|
||||||
|
providers_saving: 'Saving…',
|
||||||
|
providers_removing: 'Removing…',
|
||||||
|
providers_enter_key: 'Please enter an API key',
|
||||||
|
providers_empty: 'No configurable providers found.',
|
||||||
|
providers_key_updated: 'API key saved',
|
||||||
|
providers_key_removed: 'API key removed',
|
||||||
|
providers_key_placeholder_new: 'sk-...',
|
||||||
|
providers_key_placeholder_replace: 'Enter new key to replace…',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
create_job: 'Create job',
|
create_job: 'Create job',
|
||||||
save_skill: 'Save skill',
|
save_skill: 'Save skill',
|
||||||
|
|||||||
@@ -459,6 +459,10 @@
|
|||||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
|
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
|
||||||
<span class="settings-tab-title">Preferences</span>
|
<span class="settings-tab-title">Preferences</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="settings-tab" id="settingsTabProviders" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneProviders" onclick="switchSettingsSection('providers')">
|
||||||
|
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||||
|
<span class="settings-tab-title" data-i18n="providers_tab_title">Providers</span>
|
||||||
|
</button>
|
||||||
<button class="settings-tab" id="settingsTabSystem" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneSystem" onclick="switchSettingsSection('system')">
|
<button class="settings-tab" id="settingsTabSystem" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneSystem" onclick="switchSettingsSection('system')">
|
||||||
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
|
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
|
||||||
<span class="settings-tab-title">System</span>
|
<span class="settings-tab-title">System</span>
|
||||||
@@ -622,6 +626,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-pane" id="settingsPaneProviders" role="tabpanel" aria-labelledby="settingsTabProviders">
|
||||||
|
<div class="settings-section-head">
|
||||||
|
<div>
|
||||||
|
<div class="settings-section-title" data-i18n="providers_section_title">Providers</div>
|
||||||
|
<div class="settings-section-meta" data-i18n="providers_section_meta">Manage API keys for AI providers. Changes take effect immediately.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="providersList" style="display:flex;flex-direction:column;gap:6px;margin-top:4px">
|
||||||
|
<!-- Populated dynamically by loadProvidersPanel() -->
|
||||||
|
</div>
|
||||||
|
<div id="providersEmpty" style="display:none;text-align:center;padding:32px 0;color:var(--muted);font-size:13px" data-i18n="providers_empty">
|
||||||
|
No configurable providers found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="settings-pane" id="settingsPaneSystem" role="tabpanel" aria-labelledby="settingsTabSystem">
|
<div class="settings-pane" id="settingsPaneSystem" role="tabpanel" aria-labelledby="settingsTabSystem">
|
||||||
<div class="settings-section-head">
|
<div class="settings-section-head">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
153
static/panels.js
153
static/panels.js
@@ -1128,10 +1128,10 @@ let _settingsHermesDefaultModelOnOpen = '';
|
|||||||
let _settingsSection = 'conversation';
|
let _settingsSection = 'conversation';
|
||||||
|
|
||||||
function switchSettingsSection(name){
|
function switchSettingsSection(name){
|
||||||
const section=(name==='appearance'||name==='preferences'||name==='system')?name:'conversation';
|
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='system')?name:'conversation';
|
||||||
_settingsSection=section;
|
_settingsSection=section;
|
||||||
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',system:'System'};
|
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',system:'System'};
|
||||||
['conversation','appearance','preferences','system'].forEach(key=>{
|
['conversation','appearance','preferences','providers','system'].forEach(key=>{
|
||||||
const tab=$('settingsTab'+map[key]);
|
const tab=$('settingsTab'+map[key]);
|
||||||
const pane=$('settingsPane'+map[key]);
|
const pane=$('settingsPane'+map[key]);
|
||||||
const active=key===section;
|
const active=key===section;
|
||||||
@@ -1141,6 +1141,8 @@ function switchSettingsSection(name){
|
|||||||
}
|
}
|
||||||
if(pane) pane.classList.toggle('active',active);
|
if(pane) pane.classList.toggle('active',active);
|
||||||
});
|
});
|
||||||
|
// Lazy-load providers when the tab is opened
|
||||||
|
if(section==='providers') loadProvidersPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _syncHermesPanelSessionActions(){
|
function _syncHermesPanelSessionActions(){
|
||||||
@@ -1348,12 +1350,157 @@ async function loadSettingsPanel(){
|
|||||||
_setSettingsAuthButtonsVisible(!!authStatus.auth_enabled);
|
_setSettingsAuthButtonsVisible(!!authStatus.auth_enabled);
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
_syncHermesPanelSessionActions();
|
_syncHermesPanelSessionActions();
|
||||||
|
loadProvidersPanel(); // load provider cards in background
|
||||||
switchSettingsSection(_settingsSection);
|
switchSettingsSection(_settingsSection);
|
||||||
}catch(e){
|
}catch(e){
|
||||||
showToast(t('settings_load_failed')+e.message);
|
showToast(t('settings_load_failed')+e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Providers panel ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn}
|
||||||
|
|
||||||
|
async function loadProvidersPanel(){
|
||||||
|
const list=$('providersList');
|
||||||
|
const empty=$('providersEmpty');
|
||||||
|
if(!list) return;
|
||||||
|
try{
|
||||||
|
const data=await api('/api/providers');
|
||||||
|
const providers=(data.providers||[]).filter(p=>p.configurable);
|
||||||
|
list.innerHTML='';
|
||||||
|
_providerCardEls.clear();
|
||||||
|
if(providers.length===0){
|
||||||
|
list.style.display='none';
|
||||||
|
if(empty) empty.style.display='';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(empty) empty.style.display='none';
|
||||||
|
list.style.display='';
|
||||||
|
for(const p of providers){
|
||||||
|
list.appendChild(_buildProviderCard(p));
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
list.innerHTML='<div style="color:var(--error);padding:12px;font-size:13px">Failed to load providers: '+e.message+'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildProviderCard(p){
|
||||||
|
const card=document.createElement('div');
|
||||||
|
card.className='provider-card';
|
||||||
|
card.dataset.provider=p.id;
|
||||||
|
const isOauth=p.key_source==='oauth';
|
||||||
|
const statusColor=p.has_key?'var(--ok, #4ade80)':'var(--muted)';
|
||||||
|
const statusTitle=p.has_key?t('providers_status_configured'):t('providers_status_not_configured');
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
const header=document.createElement('div');
|
||||||
|
header.className='provider-card-header';
|
||||||
|
const info=document.createElement('div');
|
||||||
|
info.className='provider-card-info';
|
||||||
|
info.style.cssText='display:flex;align-items:center;gap:8px;';
|
||||||
|
const nameEl=document.createElement('span');
|
||||||
|
nameEl.className='provider-card-name';
|
||||||
|
nameEl.style.cssText='font-weight:600;font-size:13px;';
|
||||||
|
nameEl.textContent=p.display_name;
|
||||||
|
const dot=document.createElement('span');
|
||||||
|
dot.className='provider-card-dot';
|
||||||
|
dot.title=statusTitle;
|
||||||
|
dot.style.cssText='width:8px;height:8px;border-radius:50%;background:'+statusColor+';display:inline-block;flex-shrink:0';
|
||||||
|
const sourceEl=document.createElement('span');
|
||||||
|
sourceEl.className='provider-card-source';
|
||||||
|
sourceEl.style.cssText='font-size:11px;color:var(--muted)';
|
||||||
|
sourceEl.textContent=isOauth?t('providers_status_oauth'):(p.has_key?t('providers_status_api_key'):t('providers_status_not_configured_label'));
|
||||||
|
info.appendChild(nameEl);
|
||||||
|
info.appendChild(dot);
|
||||||
|
info.appendChild(sourceEl);
|
||||||
|
header.appendChild(info);
|
||||||
|
card.appendChild(header);
|
||||||
|
|
||||||
|
if(isOauth){
|
||||||
|
const hint=document.createElement('div');
|
||||||
|
hint.style.cssText='font-size:11px;color:var(--muted);margin-top:4px;padding-left:2px';
|
||||||
|
hint.textContent=t('providers_oauth_hint');
|
||||||
|
card.appendChild(hint);
|
||||||
|
}else{
|
||||||
|
const actions=document.createElement('div');
|
||||||
|
actions.className='provider-card-actions';
|
||||||
|
actions.style.cssText='margin-top:6px;display:flex;gap:6px;align-items:center';
|
||||||
|
const input=document.createElement('input');
|
||||||
|
input.type='password';
|
||||||
|
input.placeholder=p.has_key?t('providers_key_placeholder_replace'):t('providers_key_placeholder_new');
|
||||||
|
input.style.cssText='flex:1;padding:6px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:12px;font-family:monospace';
|
||||||
|
input.autocomplete='off';
|
||||||
|
const saveBtn=document.createElement('button');
|
||||||
|
saveBtn.className='sm-btn provider-save-btn';
|
||||||
|
saveBtn.style.cssText='padding:5px 12px;font-size:12px;white-space:nowrap';
|
||||||
|
saveBtn.textContent=t('providers_save');
|
||||||
|
saveBtn.onclick=()=>_saveProviderKey(p.id);
|
||||||
|
actions.appendChild(input);
|
||||||
|
actions.appendChild(saveBtn);
|
||||||
|
if(p.has_key){
|
||||||
|
const removeBtn=document.createElement('button');
|
||||||
|
removeBtn.className='sm-btn';
|
||||||
|
removeBtn.style.cssText='padding:5px 10px;font-size:12px;color:var(--error);border-color:rgba(233,69,96,.25);white-space:nowrap';
|
||||||
|
removeBtn.textContent=t('providers_remove');
|
||||||
|
removeBtn.onclick=()=>_removeProviderKey(p.id);
|
||||||
|
actions.appendChild(removeBtn);
|
||||||
|
}
|
||||||
|
card.appendChild(actions);
|
||||||
|
_providerCardEls.set(p.id,{card,input,saveBtn,hasKey:p.has_key});
|
||||||
|
input.addEventListener('input',()=>{saveBtn.disabled=!input.value.trim();});
|
||||||
|
saveBtn.disabled=true;
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _saveProviderKey(providerId){
|
||||||
|
const els=_providerCardEls.get(providerId);
|
||||||
|
if(!els) return;
|
||||||
|
const key=els.input.value.trim();
|
||||||
|
if(!key){
|
||||||
|
showToast(t('providers_enter_key'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
els.saveBtn.disabled=true;
|
||||||
|
els.saveBtn.textContent=t('providers_saving');
|
||||||
|
try{
|
||||||
|
const res=await api('/api/providers',{method:'POST',body:JSON.stringify({provider:providerId,api_key:key})});
|
||||||
|
if(res.ok){
|
||||||
|
showToast(res.provider+' key '+res.action);
|
||||||
|
els.input.value='';
|
||||||
|
await loadProvidersPanel(); // refresh list
|
||||||
|
}else{
|
||||||
|
showToast(res.error||'Failed to save key');
|
||||||
|
els.saveBtn.disabled=false;
|
||||||
|
els.saveBtn.textContent=t('providers_save');
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
showToast('Error: '+e.message);
|
||||||
|
els.saveBtn.disabled=false;
|
||||||
|
els.saveBtn.textContent=t('providers_save');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _removeProviderKey(providerId){
|
||||||
|
const els=_providerCardEls.get(providerId);
|
||||||
|
if(!els) return;
|
||||||
|
if(els.saveBtn){els.saveBtn.disabled=true;els.saveBtn.textContent=t('providers_removing');}
|
||||||
|
try{
|
||||||
|
const res=await api('/api/providers/delete',{method:'POST',body:JSON.stringify({provider:providerId})});
|
||||||
|
if(res.ok){
|
||||||
|
showToast(res.provider+' key '+t('providers_key_removed').toLowerCase());
|
||||||
|
await loadProvidersPanel(); // refresh list
|
||||||
|
}else{
|
||||||
|
showToast(res.error||'Failed to remove key');
|
||||||
|
if(els.saveBtn){els.saveBtn.disabled=false;els.saveBtn.textContent=t('providers_save');}
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
showToast('Error: '+e.message);
|
||||||
|
if(els.saveBtn){els.saveBtn.disabled=false;els.saveBtn.textContent=t('providers_save');}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _setSettingsAuthButtonsVisible(active){
|
function _setSettingsAuthButtonsVisible(active){
|
||||||
const signOutBtn=$('btnSignOut');
|
const signOutBtn=$('btnSignOut');
|
||||||
if(signOutBtn) signOutBtn.style.display=active?'':'none';
|
if(signOutBtn) signOutBtn.style.display=active?'':'none';
|
||||||
|
|||||||
@@ -1465,6 +1465,12 @@ body.resizing{user-select:none;cursor:col-resize;}
|
|||||||
.settings-panel .settings-btn{background:var(--accent);color:#fff;border:none;border-radius:6px;padding:8px 16px;cursor:pointer;font-weight:600;font-size:13px;}
|
.settings-panel .settings-btn{background:var(--accent);color:#fff;border:none;border-radius:6px;padding:8px 16px;cursor:pointer;font-weight:600;font-size:13px;}
|
||||||
.settings-panel .settings-btn:hover{opacity:.9;}
|
.settings-panel .settings-btn:hover{opacity:.9;}
|
||||||
|
|
||||||
|
/* ── Provider cards (settings panel) ── */
|
||||||
|
.provider-card{padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--code-bg);transition:border-color .15s;}
|
||||||
|
.provider-card:hover{border-color:var(--border2);}
|
||||||
|
.provider-card-name{font-weight:600;font-size:13px;}
|
||||||
|
.provider-card .sm-btn:disabled{opacity:.4;cursor:not-allowed;}
|
||||||
|
|
||||||
/* ── Session pin indicator (inline, only when pinned) ── */
|
/* ── Session pin indicator (inline, only when pinned) ── */
|
||||||
.session-pin-indicator{flex-shrink:0;color:#f5c542;line-height:1;display:flex;align-items:center;}
|
.session-pin-indicator{flex-shrink:0;color:#f5c542;line-height:1;display:flex;align-items:center;}
|
||||||
.session-pin-indicator svg{width:10px;height:10px;}
|
.session-pin-indicator svg{width:10px;height:10px;}
|
||||||
|
|||||||
364
tests/test_provider_management.py
Normal file
364
tests/test_provider_management.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
"""Tests for /api/providers CRUD endpoints (provider key management).
|
||||||
|
|
||||||
|
Closes #586 — allow provider key update from the WebUI.
|
||||||
|
Part of #604 — multi-provider model picker support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import api.config as config
|
||||||
|
import api.profiles as profiles
|
||||||
|
from tests._pytest_port import BASE
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path):
|
||||||
|
"""GET helper — returns parsed JSON."""
|
||||||
|
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def _post(path, body=None):
|
||||||
|
"""POST helper — returns (parsed_json, status_code)."""
|
||||||
|
data = json.dumps(body or {}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path, data=data, headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return json.loads(r.read()), r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body_text = e.read().decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
return json.loads(body_text), e.code
|
||||||
|
except Exception:
|
||||||
|
return {"error": body_text}, e.code
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_hermes_cli(monkeypatch):
|
||||||
|
"""Stub hermes_cli modules so tests are deterministic and offline."""
|
||||||
|
fake_pkg = types.ModuleType("hermes_cli")
|
||||||
|
fake_pkg.__path__ = []
|
||||||
|
|
||||||
|
fake_models = types.ModuleType("hermes_cli.models")
|
||||||
|
fake_models.list_available_providers = lambda: []
|
||||||
|
fake_models.provider_model_ids = lambda pid: []
|
||||||
|
|
||||||
|
fake_auth = types.ModuleType("hermes_cli.auth")
|
||||||
|
fake_auth.get_auth_status = lambda _pid: {}
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
|
||||||
|
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
|
||||||
|
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
|
||||||
|
monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
|
||||||
|
monkeypatch.delitem(sys.modules, "agent", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unit tests (api/providers.py functions directly) ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetProviders:
|
||||||
|
"""Unit tests for get_providers() function."""
|
||||||
|
|
||||||
|
def test_returns_list_of_known_providers(self, monkeypatch, tmp_path):
|
||||||
|
"""GET /api/providers should return a list of all known providers."""
|
||||||
|
_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"] = {}
|
||||||
|
try:
|
||||||
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||||
|
except Exception:
|
||||||
|
config._cfg_mtime = 0.0
|
||||||
|
|
||||||
|
from api.providers import get_providers
|
||||||
|
try:
|
||||||
|
result = get_providers()
|
||||||
|
assert "providers" in result
|
||||||
|
assert "active_provider" in result
|
||||||
|
assert isinstance(result["providers"], list)
|
||||||
|
# Should include at least the built-in providers
|
||||||
|
provider_ids = {p["id"] for p in result["providers"]}
|
||||||
|
assert "anthropic" in provider_ids
|
||||||
|
assert "openai" in provider_ids
|
||||||
|
assert "openrouter" in provider_ids
|
||||||
|
finally:
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg.update(old_cfg)
|
||||||
|
config._cfg_mtime = old_mtime
|
||||||
|
|
||||||
|
def test_provider_entries_have_required_fields(self, monkeypatch, tmp_path):
|
||||||
|
"""Each provider entry should have id, display_name, has_key, configurable."""
|
||||||
|
_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"] = {}
|
||||||
|
try:
|
||||||
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||||
|
except Exception:
|
||||||
|
config._cfg_mtime = 0.0
|
||||||
|
|
||||||
|
from api.providers import get_providers
|
||||||
|
try:
|
||||||
|
result = get_providers()
|
||||||
|
for p in result["providers"]:
|
||||||
|
assert "id" in p, f"Missing 'id' in provider entry"
|
||||||
|
assert "display_name" in p, f"Missing 'display_name' for {p['id']}"
|
||||||
|
assert "has_key" in p, f"Missing 'has_key' for {p['id']}"
|
||||||
|
assert "configurable" in p, f"Missing 'configurable' for {p['id']}"
|
||||||
|
assert "key_source" in p, f"Missing 'key_source' for {p['id']}"
|
||||||
|
assert isinstance(p["has_key"], bool)
|
||||||
|
assert isinstance(p["configurable"], bool)
|
||||||
|
finally:
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg.update(old_cfg)
|
||||||
|
config._cfg_mtime = old_mtime
|
||||||
|
|
||||||
|
def test_oauth_providers_not_configurable(self, monkeypatch, tmp_path):
|
||||||
|
"""OAuth providers (copilot, nous, openai-codex) should not be configurable."""
|
||||||
|
_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"] = {}
|
||||||
|
try:
|
||||||
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||||
|
except Exception:
|
||||||
|
config._cfg_mtime = 0.0
|
||||||
|
|
||||||
|
from api.providers import get_providers
|
||||||
|
try:
|
||||||
|
result = get_providers()
|
||||||
|
for p in result["providers"]:
|
||||||
|
if p["id"] in ("copilot", "nous", "openai-codex"):
|
||||||
|
assert p["configurable"] is False, f"{p['id']} should not be configurable"
|
||||||
|
# ollama-cloud is now configurable (uses OLLAMA_API_KEY)
|
||||||
|
if p["id"] == "ollama-cloud":
|
||||||
|
assert p["configurable"] is True, "ollama-cloud should be configurable"
|
||||||
|
finally:
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg.update(old_cfg)
|
||||||
|
config._cfg_mtime = old_mtime
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetProviderKey:
|
||||||
|
"""Unit tests for set_provider_key() function."""
|
||||||
|
|
||||||
|
def test_set_key_writes_to_env_file(self, monkeypatch, tmp_path):
|
||||||
|
"""Setting a key should write the env var to ~/.hermes/.env."""
|
||||||
|
_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"] = {}
|
||||||
|
try:
|
||||||
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||||
|
except Exception:
|
||||||
|
config._cfg_mtime = 0.0
|
||||||
|
|
||||||
|
from api.providers import set_provider_key
|
||||||
|
try:
|
||||||
|
result = set_provider_key("anthropic", "sk-ant-test-key-12345678")
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["provider"] == "anthropic"
|
||||||
|
assert result["action"] == "updated"
|
||||||
|
|
||||||
|
# Verify .env file was written
|
||||||
|
env_path = tmp_path / ".env"
|
||||||
|
assert env_path.exists()
|
||||||
|
content = env_path.read_text()
|
||||||
|
assert "ANTHROPIC_API_KEY=sk-ant-test-key-12345678" in content
|
||||||
|
finally:
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg.update(old_cfg)
|
||||||
|
config._cfg_mtime = old_mtime
|
||||||
|
|
||||||
|
def test_remove_key_deletes_from_env_file(self, monkeypatch, tmp_path):
|
||||||
|
"""Removing a key should delete the env var from .env."""
|
||||||
|
_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"] = {}
|
||||||
|
try:
|
||||||
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||||
|
except Exception:
|
||||||
|
config._cfg_mtime = 0.0
|
||||||
|
|
||||||
|
from api.providers import set_provider_key
|
||||||
|
try:
|
||||||
|
# First set a key
|
||||||
|
set_provider_key("anthropic", "sk-ant-test-key-12345678")
|
||||||
|
# Then remove it
|
||||||
|
result = set_provider_key("anthropic", None)
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["action"] == "removed"
|
||||||
|
|
||||||
|
# Verify .env file no longer has the key
|
||||||
|
env_path = tmp_path / ".env"
|
||||||
|
content = env_path.read_text() if env_path.exists() else ""
|
||||||
|
assert "ANTHROPIC_API_KEY" not in content
|
||||||
|
finally:
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg.update(old_cfg)
|
||||||
|
config._cfg_mtime = old_mtime
|
||||||
|
|
||||||
|
def test_oauth_provider_rejected(self, monkeypatch, tmp_path):
|
||||||
|
"""Setting a key for an OAuth provider should fail."""
|
||||||
|
_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"] = {}
|
||||||
|
try:
|
||||||
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||||
|
except Exception:
|
||||||
|
config._cfg_mtime = 0.0
|
||||||
|
|
||||||
|
from api.providers import set_provider_key
|
||||||
|
try:
|
||||||
|
result = set_provider_key("copilot", "some-key")
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "OAuth" in result["error"]
|
||||||
|
finally:
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg.update(old_cfg)
|
||||||
|
config._cfg_mtime = old_mtime
|
||||||
|
|
||||||
|
def test_short_key_rejected(self, monkeypatch, tmp_path):
|
||||||
|
"""API keys shorter than 8 chars should be rejected."""
|
||||||
|
_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"] = {}
|
||||||
|
try:
|
||||||
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||||
|
except Exception:
|
||||||
|
config._cfg_mtime = 0.0
|
||||||
|
|
||||||
|
from api.providers import set_provider_key
|
||||||
|
try:
|
||||||
|
result = set_provider_key("anthropic", "short")
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "too short" in result["error"]
|
||||||
|
finally:
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg.update(old_cfg)
|
||||||
|
config._cfg_mtime = old_mtime
|
||||||
|
|
||||||
|
def test_empty_provider_id_rejected(self, monkeypatch, tmp_path):
|
||||||
|
"""Empty provider ID should be rejected."""
|
||||||
|
from api.providers import set_provider_key
|
||||||
|
result = set_provider_key("", "some-key")
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "required" in result["error"]
|
||||||
|
|
||||||
|
def test_newline_in_key_rejected(self, monkeypatch, tmp_path):
|
||||||
|
"""API keys with newlines should be rejected."""
|
||||||
|
from api.providers import set_provider_key
|
||||||
|
result = set_provider_key("anthropic", "sk-ant-key\nINJECTED=evil")
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "newline" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveProviderKey:
|
||||||
|
"""Unit tests for remove_provider_key() wrapper."""
|
||||||
|
|
||||||
|
def test_remove_provider_key_calls_set_with_none(self, monkeypatch, tmp_path):
|
||||||
|
"""remove_provider_key should delegate to set_provider_key(id, None)."""
|
||||||
|
_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"] = {}
|
||||||
|
try:
|
||||||
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||||
|
except Exception:
|
||||||
|
config._cfg_mtime = 0.0
|
||||||
|
|
||||||
|
from api.providers import remove_provider_key
|
||||||
|
try:
|
||||||
|
result = remove_provider_key("anthropic")
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["action"] == "removed"
|
||||||
|
finally:
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg.update(old_cfg)
|
||||||
|
config._cfg_mtime = old_mtime
|
||||||
|
|
||||||
|
|
||||||
|
# ── Integration tests (via HTTP endpoints) ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestProvidersEndpoints:
|
||||||
|
"""Integration tests for /api/providers HTTP endpoints."""
|
||||||
|
|
||||||
|
def test_get_providers_returns_200(self):
|
||||||
|
"""GET /api/providers should return 200 with provider list."""
|
||||||
|
result = _get("/api/providers")
|
||||||
|
assert "providers" in result
|
||||||
|
assert isinstance(result["providers"], list)
|
||||||
|
|
||||||
|
def test_post_provider_set_key(self):
|
||||||
|
"""POST /api/providers with provider + api_key should set the key."""
|
||||||
|
body, status = _post("/api/providers", {
|
||||||
|
"provider": "anthropic",
|
||||||
|
"api_key": "sk-ant-integration-test-key-12345678",
|
||||||
|
})
|
||||||
|
assert status == 200
|
||||||
|
assert body.get("ok") is True
|
||||||
|
assert body.get("provider") == "anthropic"
|
||||||
|
|
||||||
|
def test_post_provider_remove_key(self):
|
||||||
|
"""POST /api/providers with provider but no api_key should remove the key."""
|
||||||
|
body, status = _post("/api/providers", {
|
||||||
|
"provider": "anthropic",
|
||||||
|
"api_key": None,
|
||||||
|
})
|
||||||
|
assert status == 200
|
||||||
|
assert body.get("ok") is True
|
||||||
|
assert body.get("action") == "removed"
|
||||||
|
|
||||||
|
def test_post_provider_delete(self):
|
||||||
|
"""POST /api/providers/delete should remove the key."""
|
||||||
|
body, status = _post("/api/providers/delete", {
|
||||||
|
"provider": "anthropic",
|
||||||
|
})
|
||||||
|
assert status == 200
|
||||||
|
assert body.get("ok") is True
|
||||||
|
|
||||||
|
def test_post_provider_missing_id(self):
|
||||||
|
"""POST /api/providers without provider should return 400."""
|
||||||
|
body, status = _post("/api/providers", {"api_key": "some-key"})
|
||||||
|
assert status == 400
|
||||||
|
assert "required" in body.get("error", "").lower()
|
||||||
|
|
||||||
|
def test_post_provider_delete_missing_id(self):
|
||||||
|
"""POST /api/providers/delete without provider should return 400."""
|
||||||
|
body, status = _post("/api/providers/delete", {})
|
||||||
|
assert status == 400
|
||||||
Reference in New Issue
Block a user