From c3a21c5d498d13272da853fb2936cc222894b4d3 Mon Sep 17 00:00:00 2001 From: emozilla Date: Sun, 31 May 2026 05:10:33 -0400 Subject: [PATCH] feat(desktop): route Nous subscribers onto the Tool Gateway from the GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the GUI sets the main provider to Nous via POST /api/model/set, call the same apply_nous_managed_defaults the CLI uses after model selection, so GUI/onboarding users land on the Nous Tool Gateway the same way CLI users do — no separate prompt, no duplicated logic. Purely additive: apply_nous_managed_defaults skips any tool where the user has a direct key (FIRECRAWL_API_KEY, FAL_KEY, etc.) or explicit config, so it never overwrites a user's own setup. Only unconfigured tools get routed. - web_server.py: in set_model_assignment (scope=main, provider=nous), resolve enabled toolsets and apply managed defaults; guarded so a Portal hiccup never blocks saving the model. Returns routed tools as gateway_tools. - onboarding.ts: surface a 'Tool Gateway enabled' toast listing routed tools. - types/hermes.ts: add gateway_tools to ModelAssignmentResponse. - tests: cover nous-applies, non-nous-skips, and failure-doesnt-block-save. --- apps/desktop/src/store/onboarding.ts | 33 +++++++++++++- apps/desktop/src/types/hermes.ts | 4 ++ hermes_cli/web_server.py | 38 +++++++++++++++- tests/hermes_cli/test_web_server.py | 65 ++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 642ab13ab1d..1e68064e873 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -134,6 +134,36 @@ function notifyReady(provider: string) { notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` }) } +// Human-friendly labels for tools auto-routed through the Nous Tool Gateway, +// mirroring hermes_cli/nous_subscription._GATEWAY_TOOL_LABELS so the GUI and +// CLI describe the same thing. +const GATEWAY_TOOL_LABELS: Record = { + browser: 'browser automation', + image_gen: 'image generation', + tts: 'text-to-speech', + video_gen: 'video generation', + web: 'web search & extract' +} + +// When switching to Nous auto-routes unconfigured tools through the Tool +// Gateway, tell the user which ones — same information the CLI prints. Silent +// when nothing changed (subscriber already configured, has own keys, etc.). +function notifyGatewayTools(tools: string[] | undefined) { + if (!tools || tools.length === 0) { + return + } + + const labels = tools.map(t => GATEWAY_TOOL_LABELS[t] ?? t) + const list = labels.length === 1 ? labels[0] : `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}` + + notify({ + durationMs: 8000, + kind: 'info', + message: `${list} now run through your Nous subscription — no separate API keys needed.`, + title: 'Tool Gateway enabled' + }) +} + // After credentials are persisted, ask the backend which provider+models // are now authenticated. Pick the first curated model for the matching // provider as a sensible default, persist it via /api/model/set, and @@ -221,11 +251,12 @@ async function completeWithModelConfirm( // (3) If they bail out (e.g., refresh the page), they still end up // with a working config, not an empty-model fallback. try { - await setModelAssignment({ + const res = await setModelAssignment({ scope: 'main', provider: defaults.providerSlug, model: defaults.defaultModel }) + notifyGatewayTools(res.gateway_tools) } catch { // Persistence failed — still show the confirm card so the user can // pick something explicitly. The backend will pick its own default diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index ddc7b152391..4378c7062ac 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -513,6 +513,10 @@ export interface ModelAssignmentRequest { } export interface ModelAssignmentResponse { + /** Toolset keys auto-routed through the Nous Tool Gateway as a result of + * switching the main provider to Nous. Empty unless provider === 'nous' + * and the user is a paid subscriber with unconfigured tools. */ + gateway_tools?: string[] model?: string ok: boolean provider?: string diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 903b026ae1e..305784fc96d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1396,8 +1396,44 @@ async def set_model_assignment(body: ModelAssignment): if "context_length" in model_cfg: model_cfg.pop("context_length", None) cfg["model"] = model_cfg + + # When switching the main provider to Nous, mirror the CLI's + # post-model-selection behaviour (hermes_cli/main.py + # prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults): + # auto-route any *unconfigured* tools through the Nous Tool Gateway. + # This is purely additive — apply_nous_managed_defaults skips every + # tool where the user already has a direct key (FIRECRAWL_API_KEY, + # FAL_KEY, etc.) or an explicit backend/provider in config, so it + # never overwrites a user's own setup. GUI users thus land on the + # gateway the same way CLI users do, without a separate prompt. + gateway_tools: list[str] = [] + if provider.strip().lower() == "nous": + try: + from hermes_cli.nous_subscription import apply_nous_managed_defaults + from hermes_cli.tools_config import _get_platform_tools + + enabled = _get_platform_tools( + cfg, "cli", include_default_mcp_servers=False + ) + changed = apply_nous_managed_defaults( + cfg, + enabled_toolsets=enabled, + force_fresh=True, + ) + gateway_tools = sorted(changed) + except Exception: + # Portal lookup hiccups / non-subscriber / non-nous gating + # must never block saving the model assignment. + _log.debug("apply_nous_managed_defaults skipped", exc_info=True) + save_config(cfg) - return {"ok": True, "scope": "main", "provider": provider, "model": model} + return { + "ok": True, + "scope": "main", + "provider": provider, + "model": model, + "gateway_tools": gateway_tools, + } # scope == "auxiliary" aux = cfg.get("auxiliary") diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 29e569e6949..e4cf30e18ea 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -605,6 +605,71 @@ class TestWebServerEndpoints: if resp.status_code == 200: assert "FastAPI" not in resp.text # Should not serve the actual source + def test_set_model_main_nous_applies_gateway_defaults(self, monkeypatch): + """Switching the main provider to Nous calls apply_nous_managed_defaults + (mirroring the CLI's post-model-selection Tool Gateway routing) and + surfaces the routed tools in the response.""" + import hermes_cli.nous_subscription as ns + + called = {} + + def fake_apply(config, *, enabled_toolsets=None, force_fresh=False): + called["enabled"] = set(enabled_toolsets or ()) + called["force_fresh"] = force_fresh + # Simulate routing the unconfigured web tool through the gateway. + web = config.setdefault("web", {}) + web["backend"] = "firecrawl" + return {"web"} + + monkeypatch.setattr(ns, "apply_nous_managed_defaults", fake_apply) + + resp = self.client.post( + "/api/model/set", + json={"scope": "main", "provider": "nous", "model": "hermes-4"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["provider"] == "nous" + assert data["gateway_tools"] == ["web"] + assert called["force_fresh"] is True + + def test_set_model_main_non_nous_skips_gateway_defaults(self, monkeypatch): + """Non-Nous providers must NOT trigger Tool Gateway auto-routing.""" + import hermes_cli.nous_subscription as ns + + def boom(*args, **kwargs): # pragma: no cover - must not be called + raise AssertionError("apply_nous_managed_defaults called for non-nous provider") + + monkeypatch.setattr(ns, "apply_nous_managed_defaults", boom) + + resp = self.client.post( + "/api/model/set", + json={"scope": "main", "provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data.get("gateway_tools", []) == [] + + def test_set_model_main_gateway_failure_does_not_block_save(self, monkeypatch): + """A Portal/gateway hiccup must never prevent saving the model.""" + import hermes_cli.nous_subscription as ns + + def boom(*args, **kwargs): + raise RuntimeError("portal unreachable") + + monkeypatch.setattr(ns, "apply_nous_managed_defaults", boom) + + resp = self.client.post( + "/api/model/set", + json={"scope": "main", "provider": "nous", "model": "hermes-4"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data.get("gateway_tools", []) == [] + # --------------------------------------------------------------------------- # _build_schema_from_config tests