diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4c2690be1624..a32f86d0c4cf 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -72,6 +72,7 @@ from hermes_cli.config import ( save_config, save_env_value, remove_env_value, + custom_endpoint_key_env, check_config_version, detect_install_method, format_docker_update_message, @@ -7584,6 +7585,38 @@ def _models_from_custom_endpoint_entry(entry: Dict[str, Any]) -> List[str]: return [model for model in models if model and not (model in seen or seen.add(model))] +def _api_key_display(entry: Dict[str, Any]) -> Tuple[bool, Optional[str]]: + """Return ``(has_api_key, preview)`` for a provider or model config block. + + Keys live in ``.env`` behind ``key_env``; only entries written before + #69449 still carry a plaintext ``api_key``. Checking both keeps the panel + honest either way — reading only ``api_key`` reported "no API key" for + every endpoint whose key had been moved to ``.env``. + """ + plaintext = str(entry.get("api_key") or "").strip() + if plaintext: + return True, redact_key(plaintext) + key_env = str(entry.get("key_env") or "").strip() + if key_env: + return True, f"${{{key_env}}}" + return False, None + + +def _config_api_key_is_env_ref(endpoint_id: str) -> bool: + """True when this endpoint's on-disk ``api_key`` is a ``${VAR}`` template. + + ``load_config()`` expands env refs, so a hand-written + ``api_key: ${MY_KEY}`` is indistinguishable from a literal secret by the + time it reaches us. Such an entry is already keeping its secret out of + config.yaml, so migrating it would only copy that secret into a second + env var the user didn't ask for. + """ + providers = read_raw_config().get("providers") + entry = providers.get(endpoint_id) if isinstance(providers, dict) else None + raw_key = entry.get("api_key") if isinstance(entry, dict) else None + return bool(isinstance(raw_key, str) and re.search(r"\$\{[^}]+\}", raw_key)) + + def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: model_cfg = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {} current_provider = str(model_cfg.get("provider", "") or "") @@ -7602,6 +7635,7 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: endpoint_id = str(provider_id) models = _models_from_custom_endpoint_entry(raw_entry) endpoint_model = str(raw_entry.get("model") or raw_entry.get("default_model") or (models[0] if models else "")) + has_api_key, api_key_preview = _api_key_display(raw_entry) endpoints.append({ "id": endpoint_id, "name": str(raw_entry.get("name") or endpoint_id), @@ -7610,13 +7644,14 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: "models": models, "context_length": raw_entry.get("context_length"), "discover_models": bool(raw_entry.get("discover_models", True)), - "has_api_key": bool(str(raw_entry.get("api_key", "") or "").strip()), - "api_key_preview": redact_key(str(raw_entry.get("api_key", "") or "")) if raw_entry.get("api_key") else None, + "has_api_key": has_api_key, + "api_key_preview": api_key_preview, "is_current": endpoint_id == current_provider, "source": "providers", }) if current_provider.lower() == "custom" and current_base_url and not any(e["id"] == "custom" for e in endpoints): + has_api_key, api_key_preview = _api_key_display(model_cfg) endpoints.insert(0, { "id": "custom", "name": "Custom", @@ -7625,8 +7660,8 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: "models": [current_model] if current_model else [], "context_length": model_cfg.get("context_length"), "discover_models": True, - "has_api_key": bool(str(model_cfg.get("api_key", "") or "").strip()), - "api_key_preview": redact_key(str(model_cfg.get("api_key", "") or "")) if model_cfg.get("api_key") else None, + "has_api_key": has_api_key, + "api_key_preview": api_key_preview, "is_current": True, "source": "direct-config", }) @@ -7659,7 +7694,7 @@ def _detach_main_model_from_provider(cfg: Dict[str, Any], provider_key: str) -> return if str(model_cfg.get("provider") or "").strip().lower() != provider_key: return - for field in ("provider", "base_url", "api_key"): + for field in ("provider", "base_url", "api_key", "key_env"): model_cfg.pop(field, None) cfg["model"] = model_cfg @@ -7719,8 +7754,29 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T if body.context_length and body.context_length > 0: entry["context_length"] = int(body.context_length) entry["models"][model]["context_length"] = int(body.context_length) - if body.api_key is not None and body.api_key.strip(): - entry["api_key"] = body.api_key.strip() + + # API keys never belong in config.yaml (#69449). Write to .env and + # reference it via ``key_env`` — the same indirection built-in providers + # use and that runtime_provider.py already resolves at load time. + env_var = custom_endpoint_key_env(endpoint_id) + submitted_key = body.api_key.strip() if body.api_key is not None else None + if submitted_key: + save_env_value(env_var, submitted_key) + entry["key_env"] = env_var + entry.pop("api_key", None) + elif submitted_key is not None: + # Blank field means "clear the key", not "leave it alone". + remove_env_value(env_var) + entry.pop("key_env", None) + entry.pop("api_key", None) + elif str(entry.get("api_key") or "").strip() and not _config_api_key_is_env_ref(endpoint_id): + # No new key submitted, but this entry still carries one an earlier + # release wrote in plaintext. Migrate it on the next save so endpoints + # configured before the fix get cleaned up too, without the user + # having to re-enter the key. + save_env_value(env_var, entry["api_key"].strip()) + entry["key_env"] = env_var + entry.pop("api_key", None) providers[endpoint_id] = entry cfg["providers"] = providers @@ -7729,8 +7785,9 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T cfg["model"] = _apply_main_model_assignment( cfg.get("model", {}), endpoint_id, model, base_url ) - if entry.get("api_key") and isinstance(cfg["model"], dict): - cfg["model"]["api_key"] = entry["api_key"] + if entry.get("key_env") and isinstance(cfg["model"], dict): + cfg["model"]["key_env"] = entry["key_env"] + cfg["model"].pop("api_key", None) return endpoint_id, entry @@ -7781,7 +7838,10 @@ def activate_custom_endpoint(endpoint_id: str): raise HTTPException(status_code=400, detail="custom endpoint is incomplete") model_cfg = _apply_main_model_assignment(cfg.get("model", {}), provider_key, model, base_url) - if entry.get("api_key"): + if entry.get("key_env"): + model_cfg["key_env"] = entry["key_env"] + model_cfg.pop("api_key", None) + elif entry.get("api_key"): model_cfg["api_key"] = entry["api_key"] cfg["model"] = model_cfg save_config(cfg) @@ -7805,6 +7865,7 @@ def delete_custom_endpoint(endpoint_id: str): providers.pop(provider_key, None) cfg["providers"] = providers _detach_main_model_from_provider(cfg, provider_key) + remove_env_value(custom_endpoint_key_env(provider_key)) save_config(cfg) response = _custom_endpoint_response(cfg) response["ok"] = True