From 555ec91f0f2b2d05e791df34a6784ae4dbd793c1 Mon Sep 17 00:00:00 2001 From: SacrEllfarch <2091538824thx@gmail.com> Date: Wed, 22 Jul 2026 18:44:04 +0800 Subject: [PATCH 1/3] fix(desktop): delete legacy custom providers --- .../custom-endpoints-settings.test.tsx | 122 +++++ .../settings/custom-endpoints-settings.tsx | 132 +++--- apps/desktop/src/hermes.test.ts | 17 + apps/desktop/src/hermes.ts | 12 +- hermes_cli/config.py | 14 +- hermes_cli/web_server.py | 237 +++++++++- ...test_update_config_clears_custom_fields.py | 6 + tests/hermes_cli/test_web_server.py | 422 ++++++++++++++++++ 8 files changed, 886 insertions(+), 76 deletions(-) create mode 100644 apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx diff --git a/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx b/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx new file mode 100644 index 000000000000..f2002c77c440 --- /dev/null +++ b/apps/desktop/src/app/settings/custom-endpoints-settings.test.tsx @@ -0,0 +1,122 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { CustomEndpoint, CustomEndpointsResponse } from '@/types/hermes' + +const activateCustomEndpoint = vi.fn() +const deleteCustomEndpoint = vi.fn() +const getCustomEndpoints = vi.fn() +const saveCustomEndpoint = vi.fn() +const validateCustomEndpoint = vi.fn() + +vi.mock('@/hermes', () => ({ + activateCustomEndpoint: (id: string) => activateCustomEndpoint(id), + deleteCustomEndpoint: (id: string, source?: string) => deleteCustomEndpoint(id, source), + getCustomEndpoints: () => getCustomEndpoints(), + saveCustomEndpoint: (endpoint: unknown) => saveCustomEndpoint(endpoint), + validateCustomEndpoint: (endpoint: unknown) => validateCustomEndpoint(endpoint) +})) + +vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) + +vi.mock('@/store/notifications', () => ({ + notify: vi.fn(), + notifyError: vi.fn() +})) + +function endpoint(id: string, source: string, patch: Partial = {}): CustomEndpoint { + return { + base_url: `https://${id}.example/v1`, + discover_models: true, + has_api_key: false, + id, + model: `${id}/model`, + models: [`${id}/model`], + name: id, + source, + ...patch + } +} + +function response(endpoints: CustomEndpoint[]): CustomEndpointsResponse { + return { + current: { base_url: '', model: '', provider: '' }, + endpoints + } +} + +beforeEach(() => { + activateCustomEndpoint.mockResolvedValue({ model: 'modern/model', ok: true, provider: 'modern' }) + deleteCustomEndpoint.mockResolvedValue(response([])) + saveCustomEndpoint.mockResolvedValue(response([])) + validateCustomEndpoint.mockResolvedValue({ message: '', models: [], ok: true, reachable: true }) + vi.spyOn(window, 'confirm').mockReturnValue(true) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +async function renderSettings() { + const { CustomEndpointsSettings } = await import('./custom-endpoints-settings') + + return render() +} + +describe('CustomEndpointsSettings', () => { + it('renders legacy custom_providers entries as delete-only summaries and refreshes after deletion', async () => { + const legacy = endpoint('legacy-0-abcd1234', 'custom_providers', { + api_key_preview: 'sk-...1234', + base_url: 'https://legacy-proxy.example/v1', + has_api_key: true, + is_current: true, + model: 'legacy-proxy/model', + name: 'Legacy Proxy' + }) + + getCustomEndpoints.mockResolvedValueOnce(response([legacy])).mockResolvedValueOnce(response([])) + + await renderSettings() + + expect(await screen.findByText('Legacy config')).toBeTruthy() + expect(screen.getByText('https://legacy-proxy.example/v1')).toBeTruthy() + expect(screen.getByText('legacy-proxy/model')).toBeTruthy() + expect(screen.getByText('sk-...1234')).toBeTruthy() + expect(screen.queryByRole('button', { name: /^Legacy Proxy/ })).toBeNull() + expect(screen.queryByRole('button', { name: 'Use' })).toBeNull() + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('') + + fireEvent.click(screen.getByRole('button', { name: 'Delete Legacy Proxy' })) + + await waitFor(() => expect(deleteCustomEndpoint).toHaveBeenCalledWith('legacy-0-abcd1234', 'custom_providers')) + await waitFor(() => expect(getCustomEndpoints).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(screen.queryByText('Legacy config')).toBeNull()) + }) + + it('keeps modern and direct-config endpoints selectable with their existing actions', async () => { + const modern = endpoint('modern', 'providers', { name: 'Modern Proxy' }) + const direct = endpoint('custom', 'direct-config', { name: 'Direct Custom' }) + getCustomEndpoints.mockResolvedValue(response([modern, direct])) + + await renderSettings() + await screen.findByText('Modern Proxy') + + fireEvent.click(screen.getByRole('button', { name: 'New endpoint' })) + fireEvent.click(screen.getByRole('button', { name: /^Direct Custom/ })) + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('Direct Custom') + + fireEvent.click(screen.getByRole('button', { name: 'New endpoint' })) + fireEvent.click(screen.getByRole('button', { name: /^Modern Proxy/ })) + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('Modern Proxy') + + expect(screen.getAllByRole('button', { name: 'Use' })).toHaveLength(2) + expect(screen.getByRole('button', { name: 'Delete Modern Proxy' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Delete Direct Custom' })).toBeNull() + + fireEvent.click(screen.getAllByRole('button', { name: 'Use' })[0]) + await waitFor(() => expect(activateCustomEndpoint).toHaveBeenCalledWith('modern')) + await waitFor(() => expect(getCustomEndpoints).toHaveBeenCalledTimes(2)) + }) +}) diff --git a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx index b74b30a29090..009bcece28da 100644 --- a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx +++ b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx @@ -58,6 +58,33 @@ function formFromEndpoint(endpoint: CustomEndpoint): EndpointForm { } } +function isLegacyEndpoint(endpoint: CustomEndpoint) { + return endpoint.source === 'custom_providers' +} + +function EndpointSummary({ endpoint }: { endpoint: CustomEndpoint }) { + return ( + <> +
+ {endpoint.name} + {endpoint.is_current && ( + + + Active + + )} + {isLegacyEndpoint(endpoint) && Legacy config} + {endpoint.source === 'direct-config' && config.yaml} +
+
{endpoint.base_url}
+
+ {endpoint.model} + {endpoint.has_api_key && {endpoint.api_key_preview ?? 'API key set'}} +
+ + ) +} + function toPayload(form: EndpointForm, models?: string[]): CustomEndpointUpdate { const contextLength = Number.parseInt(form.contextLength, 10) @@ -101,7 +128,8 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C } setEndpoints(data.endpoints) - const current = data.endpoints.find(endpoint => endpoint.is_current) ?? data.endpoints[0] + const editableEndpoints = data.endpoints.filter(endpoint => !isLegacyEndpoint(endpoint)) + const current = editableEndpoints.find(endpoint => endpoint.is_current) ?? editableEndpoints[0] if (current) { setForm(formFromEndpoint(current)) @@ -201,8 +229,8 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C try { setDeleting(endpoint.id) - const response = await deleteCustomEndpoint(endpoint.id) - setEndpoints(response.endpoints) + await deleteCustomEndpoint(endpoint.id, endpoint.source) + await refresh() if (form.id === endpoint.id) { setForm(EMPTY_FORM) @@ -232,59 +260,59 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
{endpoints.length ? ( - endpoints.map(endpoint => ( -
- -
- - {endpoint.source !== 'direct-config' && ( - + + )} +
+ {!isLegacy && ( + + )} + {endpoint.source !== 'direct-config' && ( + + )} +
-
- )) + ) + }) ) : ( )} diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index dff0379e103c..e64aebdaa356 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -7,6 +7,7 @@ import { AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS, audioSpeakRequestTimeoutMs, audioTranscribeRequestTimeoutMs, + deleteCustomEndpoint, getAllSessionMessages, getCronJobs, getGlobalModelInfo, @@ -310,6 +311,22 @@ describe('Hermes REST helpers', () => { } }) + it('uses a source-qualified query route only for legacy custom endpoints', async () => { + await deleteCustomEndpoint('legacy-2-a/b', 'custom_providers') + + expect(api).toHaveBeenLastCalledWith({ + method: 'DELETE', + path: '/api/providers/custom-endpoints?endpoint_id=legacy-2-a%2Fb&source=custom_providers' + }) + + await deleteCustomEndpoint('modern', 'providers') + + expect(api).toHaveBeenLastCalledWith({ + method: 'DELETE', + path: '/api/providers/custom-endpoints/modern' + }) + }) + it('keeps the liveness poll on the short default so a dead backend fails fast', async () => { api.mockResolvedValue({}) api.mockClear() diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index a80dd621dee5..412e051864dd 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -942,7 +942,17 @@ export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provi }) } -export function deleteCustomEndpoint(id: string): Promise { +export function deleteCustomEndpoint(id: string, source?: string): Promise { + if (source === 'custom_providers') { + const params = new URLSearchParams({ endpoint_id: id, source }) + + return window.hermesDesktop.api({ + ...profileScoped(), + path: `/api/providers/custom-endpoints?${params.toString()}`, + method: 'DELETE' + }) + } + return window.hermesDesktop.api({ ...profileScoped(), path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8424677d34da..10ef979223ab 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1047,21 +1047,23 @@ def clear_model_endpoint_credentials( clear_api_mode: bool = True, clear_base_url: bool = False, ) -> Dict[str, Any]: - """Remove stale inline endpoint credentials from a model config. + """Remove stale endpoint credentials and transport from a model config. - ``model.api_key`` is valid only for explicit custom endpoint assignments. - Built-in providers resolve credentials from env vars, auth.json, or the - credential pool. When switching away from a custom endpoint, leaving these - fields behind keeps secrets in config.yaml and can contaminate later custom - resolution paths. + Custom assignments may use inline keys or environment references, and may + select their API protocol through either the legacy or canonical field. + When switching away, leaving any of those aliases behind can contaminate + later provider resolution. """ if not isinstance(model_cfg, dict): return model_cfg if clear_api_key: model_cfg.pop("api_key", None) + model_cfg.pop("key_env", None) + model_cfg.pop("api_key_env", None) model_cfg.pop("api", None) if clear_api_mode: model_cfg.pop("api_mode", None) + model_cfg.pop("transport", None) if clear_base_url: model_cfg.pop("base_url", None) return model_cfg diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5e378bdda777..c27943a8b89f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -76,11 +76,13 @@ check_config_version, detect_install_method, format_docker_update_message, + get_compatible_custom_providers, recommended_update_command_for_method, redact_key, write_platform_config_field, _deep_merge, ) +from hermes_cli.providers import custom_provider_slug from plugins.memory.config_schema import ( ProviderConfigSchema, ProviderField, @@ -7325,6 +7327,104 @@ def _config_api_key_is_env_ref(endpoint_id: str) -> bool: return bool(isinstance(raw_key, str) and re.search(r"\$\{[^}]+\}", raw_key)) +def _canonical_endpoint_url(raw_url: str) -> str: + """Normalize URL components that are case-insensitive by specification.""" + value = raw_url.strip() + try: + parsed = urllib.parse.urlsplit(value) + hostname = parsed.hostname + if not parsed.scheme or not hostname: + return value.rstrip("/") + + host = f"[{hostname.lower()}]" if ":" in hostname else hostname.lower() + port = parsed.port + if port is not None and not ( + (parsed.scheme.lower() == "http" and port == 80) + or (parsed.scheme.lower() == "https" and port == 443) + ): + host = f"{host}:{port}" + + return urllib.parse.urlunsplit(( + parsed.scheme.lower(), + host, + parsed.path.rstrip("/"), + parsed.query, + "", + )) + except ValueError: + return value.rstrip("/") + + +def _custom_endpoint_signature(name: str, base_url: str, model: str) -> Tuple[str, str, str]: + """Return the compatibility identity used to deduplicate endpoint rows.""" + return ( + name.strip().lower(), + _canonical_endpoint_url(base_url), + model.strip(), + ) + + +def _legacy_custom_endpoint_id(index: int, name: str, base_url: str, model: str) -> str: + """Build a path-safe management ID for one legacy list entry. + + The runtime slug is not suitable here: different names can normalize to + the same slug, and slashes in a name do not survive a single-segment REST + route. Including the list position and a non-secret content fingerprint + also lets deletion reject a stale row after the config has changed. + """ + identity = "\0".join(_custom_endpoint_signature(name, base_url, model)) + digest = hashlib.blake2s(identity.encode("utf-8"), digest_size=6).hexdigest() + return f"legacy-{index}-{digest}" + + +def _legacy_custom_endpoint_rows(cfg: Dict[str, Any]) -> List[Dict[str, Any]]: + """Project valid legacy ``custom_providers`` entries into Desktop rows.""" + raw_entries = cfg.get("custom_providers") + if not isinstance(raw_entries, list): + return [] + + rows: List[Dict[str, Any]] = [] + for index, raw_entry in enumerate(raw_entries): + if not isinstance(raw_entry, dict): + continue + # Use the public compatibility path so aliases and model-list shapes + # stay aligned with runtime resolution. Pass a copy because the + # normalizer accepts camelCase aliases by materialising snake_case keys. + normalized = get_compatible_custom_providers({ + "custom_providers": [dict(raw_entry)], + }) + if not normalized: + continue + entry = normalized[0] + name = str(entry.get("name") or "").strip() + base_url = str(entry.get("base_url") or "").strip() + if not name or not base_url: + continue + models = _models_from_custom_endpoint_entry(entry) + model = str(entry.get("model") or (models[0] if models else "")).strip() + has_api_key, api_key_preview = _api_key_display(entry) + rows.append({ + "id": _legacy_custom_endpoint_id(index, name, base_url, model), + "_config_index": index, + "name": name, + "base_url": base_url, + "model": model, + "models": models, + "context_length": entry.get("context_length"), + "discover_models": bool(entry.get("discover_models", True)), + "has_api_key": has_api_key, + "api_key_preview": api_key_preview, + "source": "custom_providers", + }) + return rows + + +def _endpoint_url_matches(left: str, right: str) -> bool: + return bool(left.strip() and right.strip()) and ( + _canonical_endpoint_url(left) == _canonical_endpoint_url(right) + ) + + 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 "") @@ -7332,6 +7432,7 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: current_base_url = str(model_cfg.get("base_url", "") or "") endpoints: List[Dict[str, Any]] = [] + modern_by_signature: Dict[Tuple[str, str, str], Dict[str, Any]] = {} providers = cfg.get("providers") if isinstance(providers, dict): for provider_id, raw_entry in providers.items(): @@ -7342,11 +7443,16 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: continue 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 "")) + endpoint_model = str( + raw_entry.get("model") + or raw_entry.get("default_model") + or (models[0] if models else "") + ).strip() + name = str(raw_entry.get("name") or endpoint_id).strip() or endpoint_id has_api_key, api_key_preview = _api_key_display(raw_entry) - endpoints.append({ + endpoint = { "id": endpoint_id, - "name": str(raw_entry.get("name") or endpoint_id), + "name": name, "base_url": base_url, "model": endpoint_model, "models": models, @@ -7356,9 +7462,49 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: "api_key_preview": api_key_preview, "is_current": endpoint_id == current_provider, "source": "providers", - }) + } + endpoints.append(endpoint) + modern_by_signature[ + _custom_endpoint_signature(name, base_url, endpoint_model) + ] = endpoint + + for legacy_row in _legacy_custom_endpoint_rows(cfg): + endpoint = { + key: value + for key, value in legacy_row.items() + if key != "_config_index" + } + signature = _custom_endpoint_signature( + endpoint["name"], endpoint["base_url"], endpoint["model"] + ) + current_provider_lower = current_provider.strip().lower() + endpoint["is_current"] = ( + current_provider_lower + in { + custom_provider_slug(endpoint["name"]).lower(), + endpoint["name"].strip().lower(), + } + or ( + current_provider_lower == "custom" + and _endpoint_url_matches(current_base_url, endpoint["base_url"]) + ) + ) + if signature in modern_by_signature: + if endpoint["is_current"]: + modern_by_signature[signature]["is_current"] = True + continue + endpoints.append(endpoint) - if current_provider.lower() == "custom" and current_base_url and not any(e["id"] == "custom" for e in endpoints): + if ( + current_provider.lower() == "custom" + and current_base_url + and not any(e["id"] == "custom" for e in endpoints) + and not any( + e["source"] == "custom_providers" + and _endpoint_url_matches(current_base_url, e["base_url"]) + for e in endpoints + ) + ): has_api_key, api_key_preview = _api_key_display(model_cfg) endpoints.insert(0, { "id": "custom", @@ -7384,7 +7530,13 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: } -def _detach_main_model_from_provider(cfg: Dict[str, Any], provider_key: str) -> None: +def _detach_main_model_from_provider( + cfg: Dict[str, Any], + provider_key: str, + *, + base_urls: Tuple[str, ...] = (), + provider_aliases: Tuple[str, ...] = (), +) -> None: """Drop the main-slot mirror of a provider that no longer exists. ``activate_custom_endpoint`` copies the endpoint's ``base_url`` and @@ -7400,10 +7552,17 @@ def _detach_main_model_from_provider(cfg: Dict[str, Any], provider_key: str) -> model_cfg = cfg.get("model") if not isinstance(model_cfg, dict): return - if str(model_cfg.get("provider") or "").strip().lower() != provider_key: + current_provider = str(model_cfg.get("provider") or "").strip().lower() + identities = {provider_key.strip().lower()} + identities.update(alias.strip().lower() for alias in provider_aliases if alias.strip()) + if current_provider == "custom": + current_base_url = str(model_cfg.get("base_url") or "") + if not any(_endpoint_url_matches(current_base_url, url) for url in base_urls): + return + elif current_provider not in identities: return - for field in ("provider", "base_url", "api_key", "key_env"): - model_cfg.pop(field, None) + model_cfg.pop("provider", None) + clear_model_endpoint_credentials(model_cfg, clear_base_url=True) cfg["model"] = model_cfg @@ -7572,20 +7731,64 @@ def activate_custom_endpoint(endpoint_id: str, profile: Optional[str] = None): raise HTTPException(status_code=500, detail="Failed to activate custom endpoint") +@app.delete("/api/providers/custom-endpoints") @app.delete("/api/providers/custom-endpoints/{endpoint_id}") -def delete_custom_endpoint(endpoint_id: str, profile: Optional[str] = None): - """Remove a configured custom endpoint from ``providers``.""" +def delete_custom_endpoint( + endpoint_id: str, + source: Optional[str] = None, + profile: Optional[str] = None, +): + """Remove a configured custom endpoint from either supported schema.""" try: with _config_profile_scope(profile): cfg = load_config() provider_key = _custom_endpoint_id(endpoint_id) providers = cfg.get("providers") - if not isinstance(providers, dict) or provider_key not in providers: - raise HTTPException(status_code=404, detail="custom endpoint not found") - 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)) + requested_id = endpoint_id.strip() + if source not in {None, "providers", "custom_providers"}: + raise HTTPException(status_code=400, detail="unsupported custom endpoint source") + modern_provider_key: Optional[str] = None + if source != "custom_providers" and isinstance(providers, dict): + if requested_id in providers: + modern_provider_key = requested_id + elif not requested_id.lower().startswith("custom:") and provider_key in providers: + modern_provider_key = provider_key + if isinstance(providers, dict) and modern_provider_key is not None: + providers.pop(modern_provider_key, None) + cfg["providers"] = providers + _detach_main_model_from_provider( + cfg, + modern_provider_key, + provider_aliases=(custom_provider_slug(modern_provider_key),), + ) + remove_env_value(custom_endpoint_key_env(modern_provider_key)) + else: + if source != "custom_providers": + raise HTTPException(status_code=404, detail="custom endpoint not found") + raw_legacy = cfg.get("custom_providers") + if not isinstance(raw_legacy, list): + raise HTTPException(status_code=404, detail="custom endpoint not found") + + legacy_rows = _legacy_custom_endpoint_rows(cfg) + matched_rows = [row for row in legacy_rows if row["id"] == requested_id] + if not matched_rows: + raise HTTPException(status_code=404, detail="custom endpoint not found") + if len(matched_rows) > 1: + raise HTTPException(status_code=409, detail="custom endpoint id is ambiguous") + + matched_row = matched_rows[0] + matched_index = matched_row["_config_index"] + cfg["custom_providers"] = [ + raw_entry + for index, raw_entry in enumerate(raw_legacy) + if index != matched_index + ] + _detach_main_model_from_provider( + cfg, + custom_provider_slug(matched_row["name"]), + base_urls=(matched_row["base_url"],), + provider_aliases=(matched_row["name"],), + ) save_config(cfg) response = _custom_endpoint_response(cfg) response["ok"] = True diff --git a/tests/hermes_cli/test_update_config_clears_custom_fields.py b/tests/hermes_cli/test_update_config_clears_custom_fields.py index 212848c81e37..449985aac484 100644 --- a/tests/hermes_cli/test_update_config_clears_custom_fields.py +++ b/tests/hermes_cli/test_update_config_clears_custom_fields.py @@ -54,16 +54,22 @@ def test_clear_model_endpoint_credentials_removes_key_alias_and_mode(self): "provider": "openrouter", "default": "anthropic/claude-sonnet-4.6", "api_key": "sk-stale", + "key_env": "STALE_KEY", + "api_key_env": "STALE_KEY_ALIAS", "api": "sk-legacy-stale", "api_mode": "anthropic_messages", + "transport": "anthropic_messages", } returned = clear_model_endpoint_credentials(model_cfg) assert returned is model_cfg assert "api_key" not in model_cfg + assert "key_env" not in model_cfg + assert "api_key_env" not in model_cfg assert "api" not in model_cfg assert "api_mode" not in model_cfg + assert "transport" not in model_cfg assert model_cfg["provider"] == "openrouter" def test_switching_to_openrouter_clears_api_key_and_api_mode(self): diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index bc9f6084fd25..b4f1822991ed 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1518,6 +1518,177 @@ def test_set_model_main_custom_persists_api_key_and_registers_provider(self): + def test_custom_endpoints_list_includes_legacy_entries_and_redacts_keys(self): + from hermes_cli.config import save_config + + save_config({ + "model": { + "provider": "custom:acme-legacy", + "default": "acme/model-1", + "base_url": "https://legacy.acme.test/v1", + }, + "custom_providers": [ + { + "name": "Acme Legacy", + "api": "https://legacy.acme.test/v1", + "api_key": "sk-super-secret-value", + "model": "acme/model-1", + "models": ["acme/model-1", "acme/model-2"], + "discover_models": False, + }, + {"name": "Broken", "base_url": "not-a-url"}, + ], + }) + + resp = self.client.get("/api/providers/custom-endpoints") + + assert resp.status_code == 200 + data = resp.json() + assert len(data["endpoints"]) == 1 + endpoint = data["endpoints"][0] + assert endpoint["id"].startswith("legacy-0-") + assert endpoint["source"] == "custom_providers" + assert endpoint["models"] == ["acme/model-1", "acme/model-2"] + assert endpoint["discover_models"] is False + assert endpoint["has_api_key"] is True + assert endpoint["api_key_preview"] == redact_key("sk-super-secret-value") + assert endpoint["is_current"] is True + assert "sk-super-secret-value" not in resp.text + + def _delete_legacy_endpoint(self, name: str): + endpoints = self.client.get( + "/api/providers/custom-endpoints" + ).json()["endpoints"] + endpoint = next( + row + for row in endpoints + if row["source"] == "custom_providers" and row["name"] == name + ) + return self.client.request( + "DELETE", + "/api/providers/custom-endpoints", + params={"endpoint_id": endpoint["id"], "source": endpoint["source"]}, + ) + + def test_custom_endpoints_deduplicates_legacy_equivalent_of_provider_entry(self): + from hermes_cli.config import load_config, save_config + + save_config({ + "model": { + "provider": "custom:acme", + "default": "acme/m1", + "base_url": "https://llm.acme.test/v1", + "api_key": "model-secret", + "api_mode": "codex_responses", + }, + "custom_providers": [{ + "name": "Acme", + "base_url": "https://llm.acme.test/v1/", + "model": "acme/m1", + "api_key": "legacy-secret", + }], + "providers": {"acme": { + "name": " Acme ", + "api": "https://llm.acme.test/v1", + "default_model": " acme/m1 ", + "api_key": "modern-secret", + }}, + }) + + endpoints = self.client.get("/api/providers/custom-endpoints").json()["endpoints"] + + assert [(row["id"], row["source"]) for row in endpoints] == [ + ("acme", "providers") + ] + assert endpoints[0]["is_current"] is True + assert endpoints[0]["name"] == "Acme" + assert endpoints[0]["model"] == "acme/m1" + assert "legacy-secret" not in json.dumps(endpoints) + assert "modern-secret" not in json.dumps(endpoints) + + resp = self.client.request("DELETE", "/api/providers/custom-endpoints/acme") + + assert resp.status_code == 200 + cfg = load_config() + assert cfg["providers"] == {} + assert cfg["custom_providers"] == [{ + "name": "Acme", + "base_url": "https://llm.acme.test/v1/", + "model": "acme/m1", + "api_key": "legacy-secret", + }] + model_cfg = cfg["model"] + for field in ( + "provider", "base_url", "api_key", "key_env", "api_key_env", + "api", "api_mode", "transport", + ): + assert field not in model_cfg + assert [row["source"] for row in resp.json()["endpoints"]] == ["custom_providers"] + + def test_legacy_management_id_excludes_url_credentials_and_fragment(self): + from hermes_cli.config import save_config + + def endpoint_id(base_url: str) -> str: + save_config({ + "custom_providers": [{ + "name": "Acme", + "base_url": base_url, + "model": "acme/m1", + }], + }) + return self.client.get( + "/api/providers/custom-endpoints" + ).json()["endpoints"][0]["id"] + + first_id = endpoint_id("https://user:first-secret@LLM.ACME.TEST:443/v1#first") + second_id = endpoint_id("https://user:second-secret@llm.acme.test/v1#second") + + assert first_id == second_id + assert "secret" not in first_id + + def test_custom_endpoint_identity_preserves_url_path_and_model_case(self): + from hermes_cli.config import load_config, save_config + + save_config({ + "providers": {"acme": { + "name": "Acme", + "base_url": "HTTPS://LLM.ACME.TEST:443/v1/Alpha", + "model": "acme/Model", + }}, + "custom_providers": [ + { + "name": "Acme", + "base_url": "https://llm.acme.test/v1/alpha", + "model": "acme/Model", + }, + { + "name": "Acme", + "base_url": "https://llm.acme.test/v1/Alpha", + "model": "acme/model", + }, + ], + }) + + endpoints = self.client.get( + "/api/providers/custom-endpoints" + ).json()["endpoints"] + + assert [(row["source"], row["base_url"], row["model"]) for row in endpoints] == [ + ("providers", "HTTPS://LLM.ACME.TEST:443/v1/Alpha", "acme/Model"), + ("custom_providers", "https://llm.acme.test/v1/alpha", "acme/Model"), + ("custom_providers", "https://llm.acme.test/v1/Alpha", "acme/model"), + ] + + resp = self.client.request( + "DELETE", "/api/providers/custom-endpoints/acme" + ) + + assert resp.status_code == 200 + assert [row["model"] for row in load_config()["custom_providers"]] == [ + "acme/Model", + "acme/model", + ] + def _seed_custom_provider_with_key(self): from hermes_cli.config import load_config, save_config @@ -1960,6 +2131,257 @@ def tracked_get_messages(self, session_id, *args, **kwargs): assert payload["messages"][-1]["content"] == "msg 500" assert calls == [(500, 0), (500, 500)] + def test_deleting_active_legacy_endpoint_scrubs_all_model_credentials(self): + from hermes_cli.config import load_config, save_config + + save_config({ + "model": { + "provider": "custom:acme-legacy", + "default": "acme/m1", + "base_url": "https://legacy.acme.test/v1", + "api_key": "new-alias-secret", + "key_env": "ACME_KEY", + "api_key_env": "ACME_KEY_ALIAS", + "api": "old-alias-secret", + "api_mode": "anthropic_messages", + "transport": "anthropic_messages", + }, + "custom_providers": [ + { + "name": "Acme Legacy", + "base_url": "https://legacy.acme.test/v1", + "api_key": "provider-secret", + "model": "acme/m1", + }, + { + "name": "Keep Me", + "base_url": "https://keep.example/v1", + "api_key": "keep-secret", + }, + ], + }) + + resp = self._delete_legacy_endpoint("Acme Legacy") + + assert resp.status_code == 200 + cfg = load_config() + assert [row["name"] for row in cfg["custom_providers"]] == ["Keep Me"] + model_cfg = cfg["model"] + for field in ( + "provider", "base_url", "api_key", "key_env", "api_key_env", + "api", "api_mode", "transport", + ): + assert field not in model_cfg + assert model_cfg["default"] == "acme/m1" + assert "provider-secret" not in resp.text + + def test_deleting_inactive_legacy_endpoint_preserves_active_model(self): + from hermes_cli.config import load_config, save_config + + active_model = { + "provider": "other", + "default": "other/m1", + "base_url": "https://other.example/v1", + "api_key": "other-secret", + "api": "other-old-secret", + "api_mode": "chat_completions", + } + save_config({ + "model": dict(active_model), + "custom_providers": [{ + "name": "Acme Legacy", + "base_url": "https://legacy.acme.test/v1", + "api_key": "provider-secret", + }], + }) + + resp = self._delete_legacy_endpoint("Acme Legacy") + + assert resp.status_code == 200 + assert load_config()["model"] == active_model + + def test_deleting_unknown_legacy_endpoint_returns_404_without_changes(self): + from hermes_cli.config import load_config, save_config + + original = { + "model": {"provider": "nous", "default": "hermes-4"}, + "custom_providers": [{ + "name": "Acme Legacy", + "base_url": "https://legacy.acme.test/v1", + }], + } + save_config(original) + before = load_config() + + resp = self.client.request( + "DELETE", + "/api/providers/custom-endpoints", + params={ + "endpoint_id": "legacy-99-does-not-exist", + "source": "custom_providers", + }, + ) + + assert resp.status_code == 404 + assert load_config() == before + + def test_bare_custom_legacy_delete_only_detaches_matching_base_url(self): + from hermes_cli.config import load_config, save_config + + model_cfg = { + "provider": "custom", + "default": "other/m1", + "base_url": "https://other.example/v1", + "api_key": "other-secret", + "api": "other-old-secret", + "api_mode": "chat_completions", + } + save_config({ + "model": dict(model_cfg), + "custom_providers": [{ + "name": "Acme Legacy", + "base_url": "https://legacy.acme.test/v1", + "api_key": "provider-secret", + }], + }) + + resp = self._delete_legacy_endpoint("Acme Legacy") + + assert resp.status_code == 200 + assert load_config()["model"] == model_cfg + + def test_bare_custom_legacy_delete_detaches_matching_base_url(self): + from hermes_cli.config import load_config, save_config + + save_config({ + "model": { + "provider": "custom", + "default": "acme/m1", + "base_url": "https://legacy.acme.test/v1/", + "api_key": "new-alias-secret", + "key_env": "ACME_KEY", + "api_key_env": "ACME_KEY_ALIAS", + "api": "old-alias-secret", + "api_mode": "chat_completions", + "transport": "chat_completions", + }, + "custom_providers": [{ + "name": "Acme Legacy", + "base_url": "https://legacy.acme.test/v1", + "api_key": "provider-secret", + }], + }) + + resp = self._delete_legacy_endpoint("Acme Legacy") + + assert resp.status_code == 200 + model_cfg = load_config()["model"] + for field in ( + "provider", "base_url", "api_key", "key_env", "api_key_env", + "api", "api_mode", "transport", + ): + assert field not in model_cfg + + def test_bare_custom_legacy_delete_preserves_case_distinct_active_url(self): + from hermes_cli.config import load_config, save_config + + model_cfg = { + "provider": "custom", + "default": "other/m1", + "base_url": "https://legacy.acme.test/v1/Alpha", + "api_key": "other-secret", + "api_mode": "chat_completions", + } + save_config({ + "model": dict(model_cfg), + "custom_providers": [{ + "name": "Acme Legacy", + "base_url": "https://LEGACY.ACME.TEST:443/v1/alpha", + "api_key": "provider-secret", + }], + }) + + resp = self._delete_legacy_endpoint("Acme Legacy") + + assert resp.status_code == 200 + assert load_config()["model"] == model_cfg + + def test_legacy_delete_distinguishes_modern_id_and_colliding_legacy_slugs(self): + from hermes_cli.config import load_config, save_config + + save_config({ + "model": {"provider": "other", "default": "other/m1"}, + "providers": { + "custom:foo-bar": { + "name": "Modern Foo", + "base_url": "https://modern.example/v1", + "model": "modern/m1", + } + }, + "custom_providers": [ + { + "name": "Foo Bar", + "base_url": "https://first.example/v1", + "model": "first/m1", + }, + { + "name": "foo-bar", + "base_url": "https://second.example/v1", + "model": "second/m1", + }, + ], + }) + + endpoints = self.client.get( + "/api/providers/custom-endpoints" + ).json()["endpoints"] + legacy_rows = [row for row in endpoints if row["source"] == "custom_providers"] + assert len({row["id"] for row in legacy_rows}) == 2 + + target = next(row for row in legacy_rows if row["name"] == "Foo Bar") + resp = self.client.request( + "DELETE", + "/api/providers/custom-endpoints", + params={"endpoint_id": target["id"], "source": target["source"]}, + ) + + assert resp.status_code == 200 + cfg = load_config() + assert "custom:foo-bar" in cfg["providers"] + assert [row["name"] for row in cfg["custom_providers"]] == ["foo-bar"] + + def test_legacy_delete_accepts_slash_in_display_name_without_path_routing(self): + from hermes_cli.config import load_config, save_config + + save_config({ + "custom_providers": [{ + "name": "Acme/Proxy", + "base_url": "https://slash.example/v1", + "model": "slash/m1", + }], + }) + + resp = self._delete_legacy_endpoint("Acme/Proxy") + + assert resp.status_code == 200 + assert load_config()["custom_providers"] == [] + + def test_legacy_delete_requires_source_qualified_management_id(self): + from hermes_cli.config import load_config, save_config + + original = { + "custom_providers": [ + {"name": "Foo Bar", "base_url": "https://first.example/v1"}, + {"name": "foo-bar", "base_url": "https://second.example/v1"}, + ], + } + save_config(original) + + resp = self.client.request("DELETE", "/api/providers/custom-endpoints/custom:foo-bar") + + assert resp.status_code == 404 + assert load_config()["custom_providers"] == original["custom_providers"] + From 3a5f13c0616697964cef6a51da24c8136e1d35c1 Mon Sep 17 00:00:00 2001 From: SacrEllfarch <2091538824thx@gmail.com> Date: Tue, 4 Aug 2026 15:54:45 +0800 Subject: [PATCH 2/3] fix(desktop): redact legacy endpoint URLs in API --- hermes_cli/web_server.py | 36 +++++++++++++++++++++++++---- tests/hermes_cli/test_web_server.py | 22 ++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index c27943a8b89f..f717cf85dce8 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -7355,6 +7355,31 @@ def _canonical_endpoint_url(raw_url: str) -> str: return value.rstrip("/") +def _display_endpoint_url(raw_url: str) -> str: + """Remove URL credentials and fragments before returning a UI value.""" + value = raw_url.strip() + try: + parsed = urllib.parse.urlsplit(value) + if not parsed.netloc: + return value.split("#", 1)[0] + + # Keep the configured host/port spelling, but remove userinfo without + # touching the raw URL used for endpoint identity and deletion. + host = parsed.netloc.rsplit("@", 1)[-1] + return urllib.parse.urlunsplit(( + parsed.scheme, + host, + parsed.path, + parsed.query, + "", + )) + except ValueError: + # ``urlsplit`` can defer malformed-port errors until a component is + # accessed; the netloc fallback still removes any obvious userinfo. + prefix = value.split("#", 1)[0] + return prefix.rsplit("@", 1)[-1] + + def _custom_endpoint_signature(name: str, base_url: str, model: str) -> Tuple[str, str, str]: """Return the compatibility identity used to deduplicate endpoint rows.""" return ( @@ -7406,8 +7431,9 @@ def _legacy_custom_endpoint_rows(cfg: Dict[str, Any]) -> List[Dict[str, Any]]: rows.append({ "id": _legacy_custom_endpoint_id(index, name, base_url, model), "_config_index": index, + "_raw_base_url": base_url, "name": name, - "base_url": base_url, + "base_url": _display_endpoint_url(base_url), "model": model, "models": models, "context_length": entry.get("context_length"), @@ -7472,10 +7498,10 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: endpoint = { key: value for key, value in legacy_row.items() - if key != "_config_index" + if key not in {"_config_index", "_raw_base_url"} } signature = _custom_endpoint_signature( - endpoint["name"], endpoint["base_url"], endpoint["model"] + endpoint["name"], legacy_row["_raw_base_url"], endpoint["model"] ) current_provider_lower = current_provider.strip().lower() endpoint["is_current"] = ( @@ -7486,7 +7512,7 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: } or ( current_provider_lower == "custom" - and _endpoint_url_matches(current_base_url, endpoint["base_url"]) + and _endpoint_url_matches(current_base_url, legacy_row["_raw_base_url"]) ) ) if signature in modern_by_signature: @@ -7786,7 +7812,7 @@ def delete_custom_endpoint( _detach_main_model_from_provider( cfg, custom_provider_slug(matched_row["name"]), - base_urls=(matched_row["base_url"],), + base_urls=(matched_row["_raw_base_url"],), provider_aliases=(matched_row["name"],), ) save_config(cfg) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index b4f1822991ed..ec055ff8b680 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1646,6 +1646,28 @@ def endpoint_id(base_url: str) -> str: assert first_id == second_id assert "secret" not in first_id + def test_legacy_endpoint_response_strips_url_credentials_and_fragment(self): + from hermes_cli.config import load_config, save_config + + raw_url = "https://user:secret@llm.acme.test/v1?tenant=one#private" + save_config({ + "custom_providers": [{ + "name": "Acme", + "base_url": raw_url, + "model": "acme/m1", + }], + }) + + response = self.client.get("/api/providers/custom-endpoints") + + assert response.status_code == 200 + endpoint = response.json()["endpoints"][0] + assert endpoint["base_url"] == "https://llm.acme.test/v1?tenant=one" + assert "user" not in response.text + assert "secret" not in response.text + assert "private" not in response.text + assert load_config()["custom_providers"][0]["base_url"] == raw_url + def test_custom_endpoint_identity_preserves_url_path_and_model_case(self): from hermes_cli.config import load_config, save_config From c3e45b2e90f6dd5fb14e307513ad452738c8eb5b Mon Sep 17 00:00:00 2001 From: SacrEllfarch <2091538824thx@gmail.com> Date: Wed, 12 Aug 2026 18:29:29 +0800 Subject: [PATCH 3/3] test(desktop): cover profile-scoped legacy deletion --- apps/desktop/src/hermes.test.ts | 7 ++++-- tests/hermes_cli/test_web_server.py | 36 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index e64aebdaa356..c51786ec7213 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -312,18 +312,21 @@ describe('Hermes REST helpers', () => { }) it('uses a source-qualified query route only for legacy custom endpoints', async () => { + setApiRequestProfile('xiaoxuxu') await deleteCustomEndpoint('legacy-2-a/b', 'custom_providers') expect(api).toHaveBeenLastCalledWith({ method: 'DELETE', - path: '/api/providers/custom-endpoints?endpoint_id=legacy-2-a%2Fb&source=custom_providers' + path: '/api/providers/custom-endpoints?endpoint_id=legacy-2-a%2Fb&source=custom_providers', + profile: 'xiaoxuxu' }) await deleteCustomEndpoint('modern', 'providers') expect(api).toHaveBeenLastCalledWith({ method: 'DELETE', - path: '/api/providers/custom-endpoints/modern' + path: '/api/providers/custom-endpoints/modern', + profile: 'xiaoxuxu' }) }) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index ec055ff8b680..e0f6e277bd5f 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2247,6 +2247,42 @@ def test_deleting_unknown_legacy_endpoint_returns_404_without_changes(self): assert resp.status_code == 404 assert load_config() == before + def test_deleting_legacy_endpoint_scopes_to_requested_profile(self): + from hermes_cli import profiles as profiles_mod + + worker_home = profiles_mod.get_profile_dir("worker") + worker_home.mkdir(parents=True) + worker_config = worker_home / "config.yaml" + worker_config.write_text( + yaml.safe_dump({ + "custom_providers": [{ + "name": "Worker Legacy", + "base_url": "https://worker.example/v1", + }], + }), + encoding="utf-8", + ) + + endpoints = self.client.get( + "/api/providers/custom-endpoints?profile=worker" + ).json()["endpoints"] + endpoint = next(row for row in endpoints if row["name"] == "Worker Legacy") + resp = self.client.request( + "DELETE", + "/api/providers/custom-endpoints", + params={ + "endpoint_id": endpoint["id"], + "source": endpoint["source"], + "profile": "worker", + }, + ) + + assert resp.status_code == 200 + assert resp.json()["endpoints"] == [] + assert yaml.safe_load(worker_config.read_text(encoding="utf-8"))[ + "custom_providers" + ] == [] + def test_bare_custom_legacy_delete_only_detaches_matching_base_url(self): from hermes_cli.config import load_config, save_config