@bitseek/hermes-webui 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -0
- package/bin/hermes-webui.mjs +588 -0
- package/package.json +25 -0
- package/scripts/sync-vendor.mjs +74 -0
- package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
- package/templates/systemd/hermes-webui.service +13 -0
- package/templates/windows/hermes-webui-task.ps1 +3 -0
- package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
- package/vendor/agent-frontend-shell/.dockerignore +7 -0
- package/vendor/agent-frontend-shell/.env.docker.example +89 -0
- package/vendor/agent-frontend-shell/.env.example +34 -0
- package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
- package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
- package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
- package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
- package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
- package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
- package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
- package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
- package/vendor/agent-frontend-shell/AGENTS.md +80 -0
- package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
- package/vendor/agent-frontend-shell/BUGS.md +52 -0
- package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
- package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
- package/vendor/agent-frontend-shell/DESIGN.md +173 -0
- package/vendor/agent-frontend-shell/Dockerfile +91 -0
- package/vendor/agent-frontend-shell/LICENSE +21 -0
- package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
- package/vendor/agent-frontend-shell/README.md +705 -0
- package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
- package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
- package/vendor/agent-frontend-shell/TESTING.md +1932 -0
- package/vendor/agent-frontend-shell/THEMES.md +170 -0
- package/vendor/agent-frontend-shell/api/__init__.py +1 -0
- package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
- package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
- package/vendor/agent-frontend-shell/api/auth.py +592 -0
- package/vendor/agent-frontend-shell/api/background.py +87 -0
- package/vendor/agent-frontend-shell/api/clarify.py +238 -0
- package/vendor/agent-frontend-shell/api/commands.py +124 -0
- package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +5178 -0
- package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
- package/vendor/agent-frontend-shell/api/extensions.py +253 -0
- package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
- package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
- package/vendor/agent-frontend-shell/api/goals.py +608 -0
- package/vendor/agent-frontend-shell/api/helpers.py +474 -0
- package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
- package/vendor/agent-frontend-shell/api/metering.py +194 -0
- package/vendor/agent-frontend-shell/api/models.py +4210 -0
- package/vendor/agent-frontend-shell/api/oauth.py +770 -0
- package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
- package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
- package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
- package/vendor/agent-frontend-shell/api/providers.py +2175 -0
- package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
- package/vendor/agent-frontend-shell/api/rollback.py +320 -0
- package/vendor/agent-frontend-shell/api/routes.py +13990 -0
- package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
- package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
- package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
- package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
- package/vendor/agent-frontend-shell/api/session_events.py +45 -0
- package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
- package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
- package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
- package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
- package/vendor/agent-frontend-shell/api/startup.py +128 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
- package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
- package/vendor/agent-frontend-shell/api/system_health.py +167 -0
- package/vendor/agent-frontend-shell/api/terminal.py +410 -0
- package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
- package/vendor/agent-frontend-shell/api/updates.py +1261 -0
- package/vendor/agent-frontend-shell/api/upload.py +322 -0
- package/vendor/agent-frontend-shell/api/usage.py +26 -0
- package/vendor/agent-frontend-shell/api/workspace.py +867 -0
- package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
- package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
- package/vendor/agent-frontend-shell/bootstrap.py +492 -0
- package/vendor/agent-frontend-shell/ctl.sh +427 -0
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
- package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
- package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
- package/vendor/agent-frontend-shell/docker_init.bash +459 -0
- package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
- package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
- package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
- package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
- package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
- package/vendor/agent-frontend-shell/docs/docker.md +337 -0
- package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
- package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
- package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
- package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
- package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
- package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
- package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
- package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
- package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
- package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
- package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
- package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
- package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
- package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
- package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
- package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
- package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
- package/vendor/agent-frontend-shell/extensions/index.js +67 -0
- package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
- package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
- package/vendor/agent-frontend-shell/install.sh +63 -0
- package/vendor/agent-frontend-shell/mcp_server.py +567 -0
- package/vendor/agent-frontend-shell/package.json +12 -0
- package/vendor/agent-frontend-shell/pyproject.toml +56 -0
- package/vendor/agent-frontend-shell/pytest.ini +3 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +624 -0
- package/vendor/agent-frontend-shell/start.ps1 +210 -0
- package/vendor/agent-frontend-shell/start.sh +65 -0
- package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/static/boot.js +1990 -0
- package/vendor/agent-frontend-shell/static/commands.js +1402 -0
- package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
- package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
- package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
- package/vendor/agent-frontend-shell/static/icons.js +92 -0
- package/vendor/agent-frontend-shell/static/index.html +1506 -0
- package/vendor/agent-frontend-shell/static/login.js +177 -0
- package/vendor/agent-frontend-shell/static/manifest.json +53 -0
- package/vendor/agent-frontend-shell/static/messages.js +3521 -0
- package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
- package/vendor/agent-frontend-shell/static/panels.js +7995 -0
- package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
- package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
- package/vendor/agent-frontend-shell/static/style.css +4774 -0
- package/vendor/agent-frontend-shell/static/sw.js +173 -0
- package/vendor/agent-frontend-shell/static/terminal.js +632 -0
- package/vendor/agent-frontend-shell/static/ui.js +8997 -0
- package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
- package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
- package/vendor/agent-frontend-shell/static/workspace.js +680 -0
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
"""Hermes Web UI -- first-run onboarding helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
from api.auth import is_auth_enabled
|
|
15
|
+
from api.config import (
|
|
16
|
+
DEFAULT_MODEL,
|
|
17
|
+
DEFAULT_WORKSPACE,
|
|
18
|
+
_FALLBACK_MODELS,
|
|
19
|
+
_HERMES_FOUND,
|
|
20
|
+
_PROVIDER_DISPLAY,
|
|
21
|
+
_PROVIDER_MODELS,
|
|
22
|
+
_get_config_path,
|
|
23
|
+
get_available_models,
|
|
24
|
+
get_config,
|
|
25
|
+
load_settings,
|
|
26
|
+
reload_config,
|
|
27
|
+
save_settings,
|
|
28
|
+
verify_hermes_imports,
|
|
29
|
+
)
|
|
30
|
+
from api.providers import _write_env_file # shared impl with _ENV_LOCK (#1164)
|
|
31
|
+
from api.workspace import get_last_workspace, load_workspaces
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_SUPPORTED_PROVIDER_SETUPS = {
|
|
37
|
+
# ── Easy start ──────────────────────────────────────────────────────
|
|
38
|
+
"openrouter": {
|
|
39
|
+
"label": "OpenRouter",
|
|
40
|
+
"env_var": "OPENROUTER_API_KEY",
|
|
41
|
+
"default_model": "anthropic/claude-sonnet-4.6",
|
|
42
|
+
"requires_base_url": False,
|
|
43
|
+
"models": [
|
|
44
|
+
{"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS
|
|
45
|
+
],
|
|
46
|
+
"category": "easy_start",
|
|
47
|
+
"quick": True,
|
|
48
|
+
},
|
|
49
|
+
"anthropic": {
|
|
50
|
+
"label": "Anthropic",
|
|
51
|
+
"env_var": "ANTHROPIC_API_KEY",
|
|
52
|
+
"default_model": "claude-sonnet-4.6",
|
|
53
|
+
"requires_base_url": False,
|
|
54
|
+
"models": list(_PROVIDER_MODELS.get("anthropic", [])),
|
|
55
|
+
"category": "easy_start",
|
|
56
|
+
"oauth_provider": "anthropic",
|
|
57
|
+
"oauth_label": "Claude Code OAuth",
|
|
58
|
+
},
|
|
59
|
+
"openai": {
|
|
60
|
+
"label": "OpenAI",
|
|
61
|
+
"env_var": "OPENAI_API_KEY",
|
|
62
|
+
"default_model": "gpt-4o",
|
|
63
|
+
"default_base_url": "https://api.openai.com/v1",
|
|
64
|
+
"requires_base_url": False,
|
|
65
|
+
"models": list(_PROVIDER_MODELS.get("openai", [])),
|
|
66
|
+
"category": "easy_start",
|
|
67
|
+
},
|
|
68
|
+
# ── Open / self-hosted ─────────────────────────────────────────────
|
|
69
|
+
"ollama": {
|
|
70
|
+
"label": "Ollama",
|
|
71
|
+
"env_var": "OLLAMA_API_KEY",
|
|
72
|
+
"default_model": "qwen3:32b",
|
|
73
|
+
"default_base_url": "http://localhost:11434/v1",
|
|
74
|
+
"requires_base_url": True,
|
|
75
|
+
# Local Ollama runs keyless by default — only Ollama Cloud requires
|
|
76
|
+
# OLLAMA_API_KEY. The wizard accepts an empty api_key for this
|
|
77
|
+
# provider; users with auth enabled can still type one. See #1499.
|
|
78
|
+
"key_optional": True,
|
|
79
|
+
"models": [],
|
|
80
|
+
"category": "self_hosted",
|
|
81
|
+
},
|
|
82
|
+
"lmstudio": {
|
|
83
|
+
"label": "LM Studio",
|
|
84
|
+
# Canonical env var matches the agent CLI runtime (hermes_cli/auth.py:182,
|
|
85
|
+
# api_key_env_vars=("LM_API_KEY",)). Onboarding writes this name so the
|
|
86
|
+
# agent runtime actually picks up the key on the next chat — pre-#1499/#1500
|
|
87
|
+
# the WebUI wrote LMSTUDIO_API_KEY which the agent runtime ignored, masked
|
|
88
|
+
# in practice by the LMSTUDIO_NOAUTH_PLACEHOLDER fallback for keyless installs.
|
|
89
|
+
"env_var": "LM_API_KEY",
|
|
90
|
+
# Legacy env var written by older WebUI builds (≤ v0.50.272). Detection
|
|
91
|
+
# paths (_provider_api_key_present here, _provider_has_key in providers.py)
|
|
92
|
+
# also read this name so existing users with the old key in their .env
|
|
93
|
+
# don't flip to "no key" in Settings → Providers after upgrading.
|
|
94
|
+
# Onboarding only writes the canonical name going forward.
|
|
95
|
+
"env_var_aliases": ["LMSTUDIO_API_KEY"],
|
|
96
|
+
"default_model": "gpt-4o-mini",
|
|
97
|
+
"default_base_url": "http://localhost:1234/v1",
|
|
98
|
+
"requires_base_url": True,
|
|
99
|
+
# Most LM Studio installs run keyless (LMSTUDIO_NOAUTH_PLACEHOLDER on the
|
|
100
|
+
# agent side handles this). The wizard accepts an empty api_key; auth-
|
|
101
|
+
# enabled servers still need one but the user types it in the same field.
|
|
102
|
+
# See #1499 (third sub-bug from #1420).
|
|
103
|
+
"key_optional": True,
|
|
104
|
+
"models": [],
|
|
105
|
+
"category": "self_hosted",
|
|
106
|
+
},
|
|
107
|
+
"custom": {
|
|
108
|
+
"label": "Custom OpenAI-compatible",
|
|
109
|
+
"env_var": "OPENAI_API_KEY",
|
|
110
|
+
"default_model": "gpt-4o-mini",
|
|
111
|
+
"requires_base_url": True,
|
|
112
|
+
# Many self-hosted OpenAI-compatible servers (vLLM, llama-server,
|
|
113
|
+
# TabbyAPI, etc.) run keyless behind a private network. The wizard
|
|
114
|
+
# accepts an empty api_key — auth-protected endpoints can still
|
|
115
|
+
# supply one. See #1499.
|
|
116
|
+
"key_optional": True,
|
|
117
|
+
"models": [],
|
|
118
|
+
"category": "self_hosted",
|
|
119
|
+
},
|
|
120
|
+
# ── Specialized / extended ──────────────────────────────────────────
|
|
121
|
+
"gemini": {
|
|
122
|
+
"label": "Google Gemini",
|
|
123
|
+
"env_var": "GOOGLE_API_KEY",
|
|
124
|
+
"default_model": "gemini-3.1-pro-preview",
|
|
125
|
+
"default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
126
|
+
"requires_base_url": False,
|
|
127
|
+
# _PROVIDER_MODELS in api/config.py is keyed under "google" even though
|
|
128
|
+
# the agent's alias map normalizes "google" → "gemini". Use the catalog
|
|
129
|
+
# key here so the wizard surfaces the actual model list.
|
|
130
|
+
"models": list(_PROVIDER_MODELS.get("google", [])),
|
|
131
|
+
"category": "specialized",
|
|
132
|
+
},
|
|
133
|
+
"deepseek": {
|
|
134
|
+
"label": "DeepSeek",
|
|
135
|
+
"env_var": "DEEPSEEK_API_KEY",
|
|
136
|
+
"default_model": "deepseek-v4-flash",
|
|
137
|
+
"default_base_url": "https://api.deepseek.com",
|
|
138
|
+
"requires_base_url": False,
|
|
139
|
+
"models": list(_PROVIDER_MODELS.get("deepseek", [])),
|
|
140
|
+
"category": "specialized",
|
|
141
|
+
},
|
|
142
|
+
"xiaomi": {
|
|
143
|
+
"label": "Xiaomi MiMo",
|
|
144
|
+
"env_var": "XIAOMI_API_KEY",
|
|
145
|
+
"default_model": "mimo-v2.5-pro",
|
|
146
|
+
"default_base_url": "https://api.xiaomimimo.com/v1",
|
|
147
|
+
"requires_base_url": False,
|
|
148
|
+
"models": list(_PROVIDER_MODELS.get("xiaomi", [])),
|
|
149
|
+
"category": "specialized",
|
|
150
|
+
},
|
|
151
|
+
"zai": {
|
|
152
|
+
"label": "Z.AI / GLM (智谱)",
|
|
153
|
+
"env_var": "GLM_API_KEY",
|
|
154
|
+
"default_model": "glm-5.1",
|
|
155
|
+
"default_base_url": "https://open.bigmodel.cn/api/paas/v4",
|
|
156
|
+
"requires_base_url": False,
|
|
157
|
+
"models": list(_PROVIDER_MODELS.get("zai", [])),
|
|
158
|
+
"category": "specialized",
|
|
159
|
+
},
|
|
160
|
+
"nvidia": {
|
|
161
|
+
"label": "NVIDIA NIM",
|
|
162
|
+
"env_var": "NVIDIA_API_KEY",
|
|
163
|
+
"default_model": "nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
|
164
|
+
"default_base_url": "https://integrate.api.nvidia.com/v1",
|
|
165
|
+
"requires_base_url": False,
|
|
166
|
+
"models": list(_PROVIDER_MODELS.get("nvidia", [])),
|
|
167
|
+
"category": "specialized",
|
|
168
|
+
},
|
|
169
|
+
"mistralai": {
|
|
170
|
+
"label": "Mistral",
|
|
171
|
+
"env_var": "MISTRAL_API_KEY",
|
|
172
|
+
"default_model": "mistral-large-latest",
|
|
173
|
+
"default_base_url": "https://api.mistral.ai/v1",
|
|
174
|
+
"requires_base_url": False,
|
|
175
|
+
# No catalog entry for mistralai today — wizard shows a free-text input.
|
|
176
|
+
"models": list(_PROVIDER_MODELS.get("mistralai", [])),
|
|
177
|
+
"category": "specialized",
|
|
178
|
+
},
|
|
179
|
+
"x-ai": {
|
|
180
|
+
"label": "xAI (Grok)",
|
|
181
|
+
"env_var": "XAI_API_KEY",
|
|
182
|
+
"default_model": "grok-4.20",
|
|
183
|
+
"default_base_url": "https://api.x.ai/v1",
|
|
184
|
+
"requires_base_url": False,
|
|
185
|
+
# Agent normalizes "x-ai" → "xai"; _PROVIDER_MODELS is also keyed "xai"
|
|
186
|
+
# when populated, so check both keys for forward-compatibility.
|
|
187
|
+
"models": list(_PROVIDER_MODELS.get("xai", []) or _PROVIDER_MODELS.get("x-ai", [])),
|
|
188
|
+
"category": "specialized",
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_PROVIDER_CATEGORIES = [
|
|
193
|
+
{"id": "easy_start", "label": "Easy start", "order": 0},
|
|
194
|
+
{"id": "self_hosted", "label": "Open / self-hosted", "order": 1},
|
|
195
|
+
{"id": "specialized", "label": "Specialized", "order": 2},
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
_UNSUPPORTED_PROVIDER_NOTE = (
|
|
199
|
+
"Advanced provider flows such as Nous Portal and GitHub Copilot are still "
|
|
200
|
+
"terminal-first. OpenAI Codex and Anthropic Claude Code can be authenticated in this onboarding flow "
|
|
201
|
+
"when your Hermes config selects the corresponding provider."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _get_active_hermes_home() -> Path:
|
|
206
|
+
try:
|
|
207
|
+
from api.profiles import get_active_hermes_home
|
|
208
|
+
|
|
209
|
+
return get_active_hermes_home()
|
|
210
|
+
except ImportError:
|
|
211
|
+
return Path.home() / ".hermes"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _load_env_file(env_path: Path) -> dict[str, str]:
|
|
215
|
+
values: dict[str, str] = {}
|
|
216
|
+
if not env_path.exists():
|
|
217
|
+
return values
|
|
218
|
+
try:
|
|
219
|
+
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
|
220
|
+
line = raw.strip()
|
|
221
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
222
|
+
continue
|
|
223
|
+
key, value = line.split("=", 1)
|
|
224
|
+
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
225
|
+
except Exception:
|
|
226
|
+
return {}
|
|
227
|
+
return values
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _load_yaml_config(config_path: Path) -> dict:
|
|
232
|
+
try:
|
|
233
|
+
import yaml as _yaml
|
|
234
|
+
except ImportError:
|
|
235
|
+
return {}
|
|
236
|
+
|
|
237
|
+
if not config_path.exists():
|
|
238
|
+
return {}
|
|
239
|
+
try:
|
|
240
|
+
loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
241
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
242
|
+
except Exception:
|
|
243
|
+
return {}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _save_yaml_config(config_path: Path, config: dict) -> None:
|
|
247
|
+
try:
|
|
248
|
+
import yaml as _yaml
|
|
249
|
+
except ImportError as exc:
|
|
250
|
+
raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc
|
|
251
|
+
|
|
252
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
config_path.write_text(
|
|
254
|
+
_yaml.safe_dump(config, sort_keys=False, allow_unicode=True),
|
|
255
|
+
encoding="utf-8",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _normalize_model_for_provider(provider: str, model: str) -> str:
|
|
260
|
+
clean = (model or "").strip()
|
|
261
|
+
if not clean:
|
|
262
|
+
return ""
|
|
263
|
+
if provider in {"anthropic", "openai"} and clean.startswith(provider + "/"):
|
|
264
|
+
return clean.split("/", 1)[1]
|
|
265
|
+
return clean
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _normalize_base_url(base_url: str) -> str:
|
|
269
|
+
return (base_url or "").strip().rstrip("/")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── Provider endpoint probe (#1499) ─────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
# Probe error codes — stable strings the frontend can switch on for inline
|
|
275
|
+
# error rendering. Add new codes only by extending this set; never reuse.
|
|
276
|
+
PROBE_ERROR_CODES = (
|
|
277
|
+
"invalid_url", # base_url failed urlparse / scheme / host check
|
|
278
|
+
"dns", # hostname did not resolve
|
|
279
|
+
"connect_refused", # TCP RST on connect (server not listening)
|
|
280
|
+
"timeout", # exceeded probe timeout
|
|
281
|
+
"http_4xx", # endpoint returned 4xx (auth required, wrong path, …)
|
|
282
|
+
"http_5xx", # endpoint returned 5xx (server-side fault)
|
|
283
|
+
"parse", # body not JSON or not the OpenAI /models shape
|
|
284
|
+
"unreachable", # other network / SSL / unknown error
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
PROBE_TIMEOUT_SECONDS = 5.0
|
|
288
|
+
# OpenAI /models response can list dozens of entries on Ollama / LM Studio.
|
|
289
|
+
# 256 KB is more than enough for any realistic catalog and bounds the worst
|
|
290
|
+
# case for a hostile / mis-pointed endpoint that streams forever.
|
|
291
|
+
PROBE_MAX_BYTES = 256 * 1024
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
295
|
+
"""Refuse to follow HTTP redirects on the probe path.
|
|
296
|
+
|
|
297
|
+
`urllib.request.urlopen` follows redirects by default — without this
|
|
298
|
+
handler, a probe at `http://example.com/v1/models` could be redirected
|
|
299
|
+
to `http://internal-service:8080/admin`, surfacing internal HTTP services
|
|
300
|
+
to whatever the probe targets next. The probe is already gated behind
|
|
301
|
+
WebUI auth and the local-network check, so the threat model is
|
|
302
|
+
"authenticated user enumerating internal services" — same as `curl`
|
|
303
|
+
from their browser DevTools. Disabling redirects tightens defaults
|
|
304
|
+
without breaking any legitimate use case (a self-hosted /models endpoint
|
|
305
|
+
that 3xx-redirects is itself misconfigured). Redirects surface to the
|
|
306
|
+
caller as `unreachable` (mapped from `HTTPError(3xx)` in the probe).
|
|
307
|
+
Reviewer-flagged in PR #1501 (#1499 + #1500).
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
311
|
+
return None # tell urllib to NOT follow; raises HTTPError(3xx) instead
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
_PROBE_OPENER = urllib.request.build_opener(_NoRedirectHandler())
|
|
315
|
+
_DNS_ONLY_TEST_TLDS = frozenset({"invalid", "test", "example"})
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _hostname_uses_reserved_dns_tld(hostname: str | None) -> bool:
|
|
319
|
+
host = str(hostname or "").strip().rstrip(".").lower()
|
|
320
|
+
if not host or "." not in host:
|
|
321
|
+
return False
|
|
322
|
+
return host.rsplit(".", 1)[-1] in _DNS_ONLY_TEST_TLDS
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _exception_chain_text(exc) -> str:
|
|
326
|
+
parts: list[str] = []
|
|
327
|
+
seen: set[int] = set()
|
|
328
|
+
cur = exc
|
|
329
|
+
while cur is not None and id(cur) not in seen:
|
|
330
|
+
seen.add(id(cur))
|
|
331
|
+
parts.append(str(cur))
|
|
332
|
+
cur = getattr(cur, "__cause__", None) or getattr(cur, "__context__", None)
|
|
333
|
+
return " ".join(parts).lower()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _probe_failure_is_dns(exc, hostname: str | None) -> bool:
|
|
337
|
+
if isinstance(exc, socket.gaierror):
|
|
338
|
+
return True
|
|
339
|
+
text = _exception_chain_text(exc)
|
|
340
|
+
if any(
|
|
341
|
+
marker in text
|
|
342
|
+
for marker in (
|
|
343
|
+
"getaddrinfo",
|
|
344
|
+
"gaierror",
|
|
345
|
+
"name or service not known",
|
|
346
|
+
"temporary failure in name resolution",
|
|
347
|
+
"nodename nor servname provided",
|
|
348
|
+
"no address associated with hostname",
|
|
349
|
+
)
|
|
350
|
+
):
|
|
351
|
+
return True
|
|
352
|
+
return _hostname_uses_reserved_dns_tld(hostname)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def probe_provider_endpoint(
|
|
356
|
+
provider: str,
|
|
357
|
+
base_url: str,
|
|
358
|
+
api_key: str | None = None,
|
|
359
|
+
timeout: float = PROBE_TIMEOUT_SECONDS,
|
|
360
|
+
) -> dict:
|
|
361
|
+
"""Probe `<base_url>/models` for a self-hosted OpenAI-compatible provider.
|
|
362
|
+
|
|
363
|
+
Used by the onboarding wizard to validate the user's configured base URL
|
|
364
|
+
before persisting (#1499). Distinguishes failure modes so the frontend
|
|
365
|
+
can render a precise inline error instead of a generic "could not save."
|
|
366
|
+
|
|
367
|
+
Returns one of:
|
|
368
|
+
|
|
369
|
+
{"ok": True, "models": [{"id": "...", "label": "..."}, ...]}
|
|
370
|
+
{"ok": False, "error": "<code>", "detail": "<human string>"}
|
|
371
|
+
|
|
372
|
+
Where ``<code>`` is one of ``PROBE_ERROR_CODES``.
|
|
373
|
+
|
|
374
|
+
The probe is a single HTTP GET — no retries. The timeout is short by
|
|
375
|
+
design: the wizard runs the probe synchronously on the user's submit
|
|
376
|
+
click, and we'd rather report "timeout" quickly than block the UI for
|
|
377
|
+
the kernel default ~75s.
|
|
378
|
+
|
|
379
|
+
The probe response is NOT persisted. This function returns model IDs
|
|
380
|
+
so the wizard can populate its dropdown, but ``apply_onboarding_setup``
|
|
381
|
+
only writes the user's typed selection — never auto-pinning a stale
|
|
382
|
+
list of models to ``config.yaml``.
|
|
383
|
+
|
|
384
|
+
SSRF: ``base_url`` is whatever the user typed in the onboarding form.
|
|
385
|
+
The wizard is gated behind authentication (post-onboarding, the user
|
|
386
|
+
has already authenticated to the WebUI), and the legitimate target is
|
|
387
|
+
a local LM Studio / Ollama / vLLM server, so we deliberately do not
|
|
388
|
+
block private-IP ranges — that would make the feature useless. The
|
|
389
|
+
risk surface is "authenticated user crafts a probe to enumerate
|
|
390
|
+
internal HTTP services," which is a different threat model from
|
|
391
|
+
unauthenticated SSRF.
|
|
392
|
+
"""
|
|
393
|
+
base_url = _normalize_base_url(base_url)
|
|
394
|
+
if not base_url:
|
|
395
|
+
return {"ok": False, "error": "invalid_url", "detail": "base_url is required"}
|
|
396
|
+
|
|
397
|
+
parsed = urlparse(base_url)
|
|
398
|
+
if parsed.scheme not in {"http", "https"}:
|
|
399
|
+
return {
|
|
400
|
+
"ok": False,
|
|
401
|
+
"error": "invalid_url",
|
|
402
|
+
"detail": "base_url must start with http:// or https://",
|
|
403
|
+
}
|
|
404
|
+
if not parsed.hostname:
|
|
405
|
+
return {"ok": False, "error": "invalid_url", "detail": "base_url has no host"}
|
|
406
|
+
|
|
407
|
+
# Build the probe URL. OpenAI-compatible servers expose /v1/models or
|
|
408
|
+
# /models. Most users supply a base URL ending in /v1, so we just append
|
|
409
|
+
# /models to whatever they typed. Strip the trailing slash and append
|
|
410
|
+
# rather than urljoin to avoid eating the /v1 segment when there's no
|
|
411
|
+
# trailing slash.
|
|
412
|
+
probe_url = f"{base_url}/models"
|
|
413
|
+
|
|
414
|
+
headers = {
|
|
415
|
+
"Accept": "application/json",
|
|
416
|
+
"User-Agent": "hermes-webui-onboarding-probe",
|
|
417
|
+
}
|
|
418
|
+
if api_key:
|
|
419
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
420
|
+
|
|
421
|
+
req = urllib.request.Request(probe_url, headers=headers, method="GET")
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
with _PROBE_OPENER.open(req, timeout=timeout) as resp:
|
|
425
|
+
status = resp.status
|
|
426
|
+
body = resp.read(PROBE_MAX_BYTES + 1)
|
|
427
|
+
except urllib.error.HTTPError as exc:
|
|
428
|
+
# 3xx / 4xx / 5xx with a body — categorize. 3xx happens when the
|
|
429
|
+
# endpoint redirects (we refuse to follow on the probe path — see
|
|
430
|
+
# _NoRedirectHandler). Map to `unreachable` rather than introducing a
|
|
431
|
+
# new error code, since a self-hosted /models endpoint that 3xx-
|
|
432
|
+
# redirects is itself misconfigured.
|
|
433
|
+
if 300 <= exc.code < 400:
|
|
434
|
+
code = "unreachable"
|
|
435
|
+
detail = (
|
|
436
|
+
f"HTTP {exc.code} — endpoint returned a redirect "
|
|
437
|
+
f"(probe does not follow redirects). Point base_url at the "
|
|
438
|
+
f"final URL directly."
|
|
439
|
+
)
|
|
440
|
+
return {"ok": False, "error": code, "detail": detail, "status": exc.code}
|
|
441
|
+
code = "http_4xx" if 400 <= exc.code < 500 else "http_5xx"
|
|
442
|
+
# Try to surface a useful detail (LM Studio sometimes returns text/plain).
|
|
443
|
+
try:
|
|
444
|
+
err_body = exc.read(2048).decode("utf-8", errors="replace").strip()
|
|
445
|
+
except Exception:
|
|
446
|
+
err_body = ""
|
|
447
|
+
detail = f"HTTP {exc.code}"
|
|
448
|
+
if err_body:
|
|
449
|
+
err_first = err_body.splitlines()[0][:200]
|
|
450
|
+
detail = f"{detail}: {err_first}"
|
|
451
|
+
return {"ok": False, "error": code, "detail": detail, "status": exc.code}
|
|
452
|
+
except urllib.error.URLError as exc:
|
|
453
|
+
# Distinguish DNS / connect-refused / timeout / generic.
|
|
454
|
+
reason = exc.reason
|
|
455
|
+
if isinstance(reason, socket.timeout) or "timed out" in str(reason).lower():
|
|
456
|
+
return {"ok": False, "error": "timeout", "detail": f"connection timed out after {timeout:g}s"}
|
|
457
|
+
if _probe_failure_is_dns(reason, parsed.hostname):
|
|
458
|
+
return {
|
|
459
|
+
"ok": False,
|
|
460
|
+
"error": "dns",
|
|
461
|
+
"detail": f"could not resolve host '{parsed.hostname}'",
|
|
462
|
+
}
|
|
463
|
+
if isinstance(reason, ConnectionRefusedError) or "refused" in str(reason).lower():
|
|
464
|
+
port_hint = parsed.port or ("443" if parsed.scheme == "https" else "80")
|
|
465
|
+
return {
|
|
466
|
+
"ok": False,
|
|
467
|
+
"error": "connect_refused",
|
|
468
|
+
"detail": f"connection refused at {parsed.hostname}:{port_hint}",
|
|
469
|
+
}
|
|
470
|
+
return {"ok": False, "error": "unreachable", "detail": str(reason)[:200]}
|
|
471
|
+
except (TimeoutError, socket.timeout):
|
|
472
|
+
return {"ok": False, "error": "timeout", "detail": f"connection timed out after {timeout:g}s"}
|
|
473
|
+
except Exception as exc: # pragma: no cover — defensive net
|
|
474
|
+
if _probe_failure_is_dns(exc, parsed.hostname):
|
|
475
|
+
return {
|
|
476
|
+
"ok": False,
|
|
477
|
+
"error": "dns",
|
|
478
|
+
"detail": f"could not resolve host '{parsed.hostname}'",
|
|
479
|
+
}
|
|
480
|
+
logger.debug("probe_provider_endpoint unexpected error", exc_info=True)
|
|
481
|
+
return {"ok": False, "error": "unreachable", "detail": str(exc)[:200]}
|
|
482
|
+
|
|
483
|
+
# If the response was huge, refuse to parse. 256 KB cap is generous;
|
|
484
|
+
# anything bigger is likely the user pointed us at the wrong service.
|
|
485
|
+
if len(body) > PROBE_MAX_BYTES:
|
|
486
|
+
return {
|
|
487
|
+
"ok": False,
|
|
488
|
+
"error": "parse",
|
|
489
|
+
"detail": f"response exceeded {PROBE_MAX_BYTES // 1024} KB cap",
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
payload = json.loads(body.decode("utf-8", errors="replace"))
|
|
494
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
495
|
+
return {
|
|
496
|
+
"ok": False,
|
|
497
|
+
"error": "parse",
|
|
498
|
+
"detail": f"response is not JSON ({exc.__class__.__name__})",
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
# Accept both the OpenAI shape (`{"data": [{"id": ...}, ...]}`) and the
|
|
502
|
+
# bare-list shape some self-hosted servers return (`[{"id": ...}, ...]`).
|
|
503
|
+
if isinstance(payload, dict) and isinstance(payload.get("data"), list):
|
|
504
|
+
entries = payload["data"]
|
|
505
|
+
elif isinstance(payload, list):
|
|
506
|
+
entries = payload
|
|
507
|
+
else:
|
|
508
|
+
return {
|
|
509
|
+
"ok": False,
|
|
510
|
+
"error": "parse",
|
|
511
|
+
"detail": "response is not in OpenAI /models shape (expected {'data': [...]} or [...])",
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
models = []
|
|
515
|
+
for entry in entries:
|
|
516
|
+
if isinstance(entry, dict) and entry.get("id"):
|
|
517
|
+
mid = str(entry["id"]).strip()
|
|
518
|
+
if mid:
|
|
519
|
+
models.append({"id": mid, "label": mid})
|
|
520
|
+
elif isinstance(entry, str) and entry.strip():
|
|
521
|
+
models.append({"id": entry.strip(), "label": entry.strip()})
|
|
522
|
+
|
|
523
|
+
return {"ok": True, "models": models, "status": status}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _extract_current_provider(cfg: dict) -> str:
|
|
527
|
+
model_cfg = cfg.get("model", {})
|
|
528
|
+
if isinstance(model_cfg, dict):
|
|
529
|
+
provider = str(model_cfg.get("provider") or "").strip().lower()
|
|
530
|
+
if provider:
|
|
531
|
+
return provider
|
|
532
|
+
return ""
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _extract_current_model(cfg: dict) -> str:
|
|
536
|
+
model_cfg = cfg.get("model", {})
|
|
537
|
+
if isinstance(model_cfg, str):
|
|
538
|
+
return model_cfg.strip()
|
|
539
|
+
if isinstance(model_cfg, dict):
|
|
540
|
+
return str(model_cfg.get("default") or "").strip()
|
|
541
|
+
return ""
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _extract_current_base_url(cfg: dict) -> str:
|
|
545
|
+
model_cfg = cfg.get("model", {})
|
|
546
|
+
if isinstance(model_cfg, dict):
|
|
547
|
+
return _normalize_base_url(str(model_cfg.get("base_url") or ""))
|
|
548
|
+
return ""
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _provider_api_key_present(
|
|
552
|
+
provider: str, cfg: dict, env_values: dict[str, str]
|
|
553
|
+
) -> bool:
|
|
554
|
+
provider = (provider or "").strip().lower()
|
|
555
|
+
if not provider:
|
|
556
|
+
return False
|
|
557
|
+
|
|
558
|
+
env_var = _SUPPORTED_PROVIDER_SETUPS.get(provider, {}).get("env_var")
|
|
559
|
+
if env_var and env_values.get(env_var):
|
|
560
|
+
return True
|
|
561
|
+
|
|
562
|
+
# Legacy env-var aliases (read-only fallback for env vars renamed in past
|
|
563
|
+
# releases — e.g. lmstudio's LM_API_KEY canonical + LMSTUDIO_API_KEY legacy
|
|
564
|
+
# in #1500). Canonical name is what onboarding writes going forward;
|
|
565
|
+
# aliases keep existing users' detection working without forcing an .env
|
|
566
|
+
# rewrite.
|
|
567
|
+
for alias in _SUPPORTED_PROVIDER_SETUPS.get(provider, {}).get("env_var_aliases", []) or []:
|
|
568
|
+
if alias and env_values.get(alias):
|
|
569
|
+
return True
|
|
570
|
+
|
|
571
|
+
model_cfg = cfg.get("model", {})
|
|
572
|
+
if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
|
|
573
|
+
return True
|
|
574
|
+
|
|
575
|
+
providers_cfg = cfg.get("providers", {})
|
|
576
|
+
if isinstance(providers_cfg, dict):
|
|
577
|
+
provider_cfg = providers_cfg.get(provider, {})
|
|
578
|
+
if (
|
|
579
|
+
isinstance(provider_cfg, dict)
|
|
580
|
+
and str(provider_cfg.get("api_key") or "").strip()
|
|
581
|
+
):
|
|
582
|
+
return True
|
|
583
|
+
if provider == "custom":
|
|
584
|
+
custom_cfg = providers_cfg.get("custom", {})
|
|
585
|
+
if (
|
|
586
|
+
isinstance(custom_cfg, dict)
|
|
587
|
+
and str(custom_cfg.get("api_key") or "").strip()
|
|
588
|
+
):
|
|
589
|
+
return True
|
|
590
|
+
|
|
591
|
+
# For providers not in _SUPPORTED_PROVIDER_SETUPS (e.g. minimax-cn, deepseek,
|
|
592
|
+
# xai, etc.), ask the hermes_cli auth registry — it knows every provider's env
|
|
593
|
+
# var names and can check os.environ for a valid key.
|
|
594
|
+
# Exclude known OAuth/token-flow providers — those are handled separately by
|
|
595
|
+
# _provider_oauth_authenticated() and should not be short-circuited here.
|
|
596
|
+
_known_oauth = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous", "anthropic"}
|
|
597
|
+
if provider not in _SUPPORTED_PROVIDER_SETUPS and provider not in _known_oauth:
|
|
598
|
+
try:
|
|
599
|
+
from hermes_cli.auth import get_auth_status as _gas
|
|
600
|
+
status = _gas(provider)
|
|
601
|
+
if isinstance(status, dict) and status.get("logged_in"):
|
|
602
|
+
return True
|
|
603
|
+
except Exception:
|
|
604
|
+
pass
|
|
605
|
+
|
|
606
|
+
return False
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _oauth_payload_has_token(payload: dict) -> bool:
|
|
611
|
+
"""Return True if an auth payload contains usable token material."""
|
|
612
|
+
if not isinstance(payload, dict):
|
|
613
|
+
return False
|
|
614
|
+
|
|
615
|
+
token_fields = (
|
|
616
|
+
payload,
|
|
617
|
+
payload.get("tokens") if isinstance(payload.get("tokens"), dict) else {},
|
|
618
|
+
)
|
|
619
|
+
for candidate in token_fields:
|
|
620
|
+
if not isinstance(candidate, dict):
|
|
621
|
+
continue
|
|
622
|
+
if any(
|
|
623
|
+
str(candidate.get(key) or "").strip()
|
|
624
|
+
for key in ("access_token", "refresh_token", "api_key")
|
|
625
|
+
):
|
|
626
|
+
return True
|
|
627
|
+
return False
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
|
|
632
|
+
"""Return True if the provider has valid OAuth credentials.
|
|
633
|
+
|
|
634
|
+
Reads the profile-scoped auth.json directly so onboarding respects the
|
|
635
|
+
requested Hermes home. Known OAuth providers may store auth either in the
|
|
636
|
+
legacy providers[provider_id] singleton state or in credential_pool entries
|
|
637
|
+
used by current Hermes runtime auth resolution.
|
|
638
|
+
"""
|
|
639
|
+
provider = (provider or "").strip().lower()
|
|
640
|
+
provider = {"claude": "anthropic", "claude-code": "anthropic"}.get(provider, provider)
|
|
641
|
+
if not provider:
|
|
642
|
+
return False
|
|
643
|
+
|
|
644
|
+
_known_oauth_providers = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous", "anthropic"}
|
|
645
|
+
if provider not in _known_oauth_providers:
|
|
646
|
+
return False
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
import json as _j
|
|
650
|
+
|
|
651
|
+
auth_path = hermes_home / "auth.json"
|
|
652
|
+
if not auth_path.exists():
|
|
653
|
+
return False
|
|
654
|
+
store = _j.loads(auth_path.read_text(encoding="utf-8"))
|
|
655
|
+
|
|
656
|
+
providers_store = store.get("providers")
|
|
657
|
+
if isinstance(providers_store, dict):
|
|
658
|
+
state = providers_store.get(provider)
|
|
659
|
+
if _oauth_payload_has_token(state):
|
|
660
|
+
return True
|
|
661
|
+
|
|
662
|
+
pool_store = store.get("credential_pool")
|
|
663
|
+
if isinstance(pool_store, dict):
|
|
664
|
+
entries = pool_store.get(provider)
|
|
665
|
+
if isinstance(entries, list):
|
|
666
|
+
for entry in entries:
|
|
667
|
+
if _oauth_payload_has_token(entry):
|
|
668
|
+
return True
|
|
669
|
+
if (
|
|
670
|
+
provider == "anthropic"
|
|
671
|
+
and isinstance(entry, dict)
|
|
672
|
+
and entry.get("auth_type") == "oauth"
|
|
673
|
+
and entry.get("source") == "claude_code_linked"
|
|
674
|
+
):
|
|
675
|
+
return True
|
|
676
|
+
|
|
677
|
+
return False
|
|
678
|
+
except Exception:
|
|
679
|
+
return False
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict:
|
|
683
|
+
provider = _extract_current_provider(cfg)
|
|
684
|
+
model = _extract_current_model(cfg)
|
|
685
|
+
base_url = _extract_current_base_url(cfg)
|
|
686
|
+
env_values = _load_env_file(_get_active_hermes_home() / ".env")
|
|
687
|
+
|
|
688
|
+
provider_configured = bool(provider and model)
|
|
689
|
+
provider_ready = False
|
|
690
|
+
|
|
691
|
+
if provider_configured:
|
|
692
|
+
meta = _SUPPORTED_PROVIDER_SETUPS.get(provider, {})
|
|
693
|
+
if provider in _SUPPORTED_PROVIDER_SETUPS:
|
|
694
|
+
# key_optional providers (lmstudio, ollama, custom) are ready as
|
|
695
|
+
# soon as the user has saved a provider+model+base_url; an api_key
|
|
696
|
+
# is allowed but not required. The agent runtime substitutes a
|
|
697
|
+
# placeholder for keyless local servers (LMSTUDIO_NOAUTH_PLACEHOLDER
|
|
698
|
+
# for lmstudio, equivalent paths for ollama / custom). See #1499
|
|
699
|
+
# third sub-bug from #1420.
|
|
700
|
+
if meta.get("key_optional"):
|
|
701
|
+
if meta.get("requires_base_url"):
|
|
702
|
+
provider_ready = bool(base_url)
|
|
703
|
+
else:
|
|
704
|
+
provider_ready = True
|
|
705
|
+
else:
|
|
706
|
+
# Standard wizard provider (openrouter, anthropic, openai, gemini,
|
|
707
|
+
# deepseek, zai, …) — needs an api_key. Custom historically also
|
|
708
|
+
# took this branch, but is now key_optional via the meta flag.
|
|
709
|
+
if meta.get("requires_base_url"):
|
|
710
|
+
provider_ready = bool(
|
|
711
|
+
base_url
|
|
712
|
+
and _provider_api_key_present(provider, cfg, env_values)
|
|
713
|
+
)
|
|
714
|
+
else:
|
|
715
|
+
provider_ready = _provider_api_key_present(provider, cfg, env_values)
|
|
716
|
+
if not provider_ready and meta.get("oauth_provider"):
|
|
717
|
+
provider_ready = _provider_oauth_authenticated(
|
|
718
|
+
str(meta.get("oauth_provider")), _get_active_hermes_home()
|
|
719
|
+
)
|
|
720
|
+
else:
|
|
721
|
+
# Unknown provider — may be an OAuth flow (openai-codex, copilot, etc.)
|
|
722
|
+
# OR an API-key provider not in the quick-setup list (minimax-cn, deepseek,
|
|
723
|
+
# xai, etc.). Check both: api key presence first (covers the majority of
|
|
724
|
+
# third-party providers), then OAuth auth.json.
|
|
725
|
+
provider_ready = (
|
|
726
|
+
_provider_api_key_present(provider, cfg, env_values)
|
|
727
|
+
or _provider_oauth_authenticated(provider, _get_active_hermes_home())
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
chat_ready = bool(_HERMES_FOUND and imports_ok and provider_ready)
|
|
731
|
+
|
|
732
|
+
if not _HERMES_FOUND or not imports_ok:
|
|
733
|
+
state = "agent_unavailable"
|
|
734
|
+
note = (
|
|
735
|
+
"Hermes is not fully importable from the Web UI yet. Finish bootstrap or fix the "
|
|
736
|
+
"agent install before provider setup will work."
|
|
737
|
+
)
|
|
738
|
+
elif chat_ready:
|
|
739
|
+
state = "ready"
|
|
740
|
+
provider_name = _PROVIDER_DISPLAY.get(
|
|
741
|
+
provider, provider.title() if provider else "Hermes"
|
|
742
|
+
)
|
|
743
|
+
note = f"Hermes is minimally configured and ready to chat via {provider_name}."
|
|
744
|
+
elif provider_configured:
|
|
745
|
+
state = "provider_incomplete"
|
|
746
|
+
if provider == "custom" and not base_url:
|
|
747
|
+
note = (
|
|
748
|
+
"Hermes has a saved provider/model selection but still needs the "
|
|
749
|
+
"base URL and API key required to chat."
|
|
750
|
+
)
|
|
751
|
+
elif provider not in _SUPPORTED_PROVIDER_SETUPS:
|
|
752
|
+
# OAuth / unsupported provider: avoid misleading "API key" wording.
|
|
753
|
+
note = (
|
|
754
|
+
f"Provider '{provider}' is configured but not yet authenticated. "
|
|
755
|
+
"Run 'hermes auth' or 'hermes model' in a terminal to complete "
|
|
756
|
+
"setup, then reload the Web UI."
|
|
757
|
+
)
|
|
758
|
+
else:
|
|
759
|
+
note = (
|
|
760
|
+
"Hermes has a saved provider/model selection but still needs the "
|
|
761
|
+
"API key required to chat."
|
|
762
|
+
)
|
|
763
|
+
else:
|
|
764
|
+
state = "needs_provider"
|
|
765
|
+
note = "Hermes is installed, but you still need to choose a provider and save working credentials."
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
"provider_configured": provider_configured,
|
|
769
|
+
"provider_ready": provider_ready,
|
|
770
|
+
"chat_ready": chat_ready,
|
|
771
|
+
"setup_state": state,
|
|
772
|
+
"provider_note": note,
|
|
773
|
+
"current_provider": provider or None,
|
|
774
|
+
"current_model": model or None,
|
|
775
|
+
"current_base_url": base_url or None,
|
|
776
|
+
"env_path": str(_get_active_hermes_home() / ".env"),
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _build_setup_catalog(cfg: dict) -> dict:
|
|
781
|
+
current_provider = _extract_current_provider(cfg) or "openrouter"
|
|
782
|
+
current_model = _extract_current_model(cfg)
|
|
783
|
+
current_base_url = _extract_current_base_url(cfg)
|
|
784
|
+
|
|
785
|
+
providers = []
|
|
786
|
+
for provider_id, meta in _SUPPORTED_PROVIDER_SETUPS.items():
|
|
787
|
+
providers.append(
|
|
788
|
+
{
|
|
789
|
+
"id": provider_id,
|
|
790
|
+
"label": meta["label"],
|
|
791
|
+
"env_var": meta["env_var"],
|
|
792
|
+
"default_model": meta["default_model"],
|
|
793
|
+
"default_base_url": meta.get("default_base_url") or "",
|
|
794
|
+
"requires_base_url": bool(meta.get("requires_base_url")),
|
|
795
|
+
# #1499 (third sub-bug from #1420) — providers that may run
|
|
796
|
+
# keyless (lmstudio, ollama, custom). Frontend uses this to
|
|
797
|
+
# show a "(optional)" hint and allow Continue without a key.
|
|
798
|
+
"key_optional": bool(meta.get("key_optional")),
|
|
799
|
+
"models": list(meta.get("models", [])),
|
|
800
|
+
"category": meta.get("category", "easy_start"),
|
|
801
|
+
"quick": meta.get("quick", False),
|
|
802
|
+
"oauth_provider": meta.get("oauth_provider") or "",
|
|
803
|
+
"oauth_label": meta.get("oauth_label") or "",
|
|
804
|
+
}
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
# Sort providers by category order, then alphabetically within each category.
|
|
808
|
+
cat_order = {c["id"]: c["order"] for c in _PROVIDER_CATEGORIES}
|
|
809
|
+
providers.sort(key=lambda p: (cat_order.get(p["category"], 99), p["label"]))
|
|
810
|
+
|
|
811
|
+
# Group providers by category for the frontend.
|
|
812
|
+
categories = []
|
|
813
|
+
for cat in sorted(_PROVIDER_CATEGORIES, key=lambda c: c["order"]):
|
|
814
|
+
categories.append({
|
|
815
|
+
"id": cat["id"],
|
|
816
|
+
"label": cat["label"],
|
|
817
|
+
"providers": [p["id"] for p in providers if p["category"] == cat["id"]],
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
# Flag whether the currently-configured provider is OAuth-based (not in the
|
|
821
|
+
# API-key flow). The frontend uses this to show a confirmation card instead
|
|
822
|
+
# of a key input when the user has already authenticated via 'hermes auth'.
|
|
823
|
+
current_is_oauth = (
|
|
824
|
+
current_provider not in _SUPPORTED_PROVIDER_SETUPS and bool(current_provider)
|
|
825
|
+
) or _provider_oauth_authenticated(current_provider, _get_active_hermes_home())
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
"providers": providers,
|
|
829
|
+
"categories": categories,
|
|
830
|
+
"unsupported_note": _UNSUPPORTED_PROVIDER_NOTE,
|
|
831
|
+
"current_is_oauth": current_is_oauth,
|
|
832
|
+
"current": {
|
|
833
|
+
"provider": current_provider,
|
|
834
|
+
"model": current_model
|
|
835
|
+
or _SUPPORTED_PROVIDER_SETUPS.get(current_provider, {}).get(
|
|
836
|
+
"default_model", ""
|
|
837
|
+
),
|
|
838
|
+
"base_url": current_base_url,
|
|
839
|
+
},
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def get_onboarding_status() -> dict:
|
|
844
|
+
settings = load_settings()
|
|
845
|
+
cfg = get_config()
|
|
846
|
+
imports_ok, missing, errors = verify_hermes_imports()
|
|
847
|
+
runtime = _status_from_runtime(cfg, imports_ok)
|
|
848
|
+
workspaces = load_workspaces()
|
|
849
|
+
last_workspace = get_last_workspace()
|
|
850
|
+
available_models = get_available_models()
|
|
851
|
+
|
|
852
|
+
# HERMES_WEBUI_SKIP_ONBOARDING=1 lets hosting providers (e.g. Agent37) ship
|
|
853
|
+
# a pre-configured instance without the wizard blocking the first load.
|
|
854
|
+
# This is an operator-level override and is honoured unconditionally —
|
|
855
|
+
# the operator knows their deployment is configured; we must not second-guess
|
|
856
|
+
# it by requiring chat_ready to also be true.
|
|
857
|
+
skip_env = os.environ.get("HERMES_WEBUI_SKIP_ONBOARDING", "").strip()
|
|
858
|
+
skip_requested = skip_env in {"1", "true", "yes"}
|
|
859
|
+
auto_completed = skip_requested # unconditional: operator says skip, we skip
|
|
860
|
+
|
|
861
|
+
# Auto-complete for existing Hermes users: if config.yaml already exists
|
|
862
|
+
# AND the provider is configured (or the system is chat_ready), treat onboarding
|
|
863
|
+
# as done. These users configured Hermes via the CLI before the Web UI existed;
|
|
864
|
+
# they must never be shown the first-run wizard — it would silently overwrite their
|
|
865
|
+
# config. We use provider_configured (not chat_ready) so that users with
|
|
866
|
+
# non-wizard providers (ollama-cloud, deepseek, xai, kimi, etc.) are not forced
|
|
867
|
+
# through the wizard just because their provider doesn't have a detectable API key
|
|
868
|
+
# — the wizard cannot represent their provider and would overwrite their config
|
|
869
|
+
# with whichever wizard-supported provider they accidentally select.
|
|
870
|
+
config_exists = Path(_get_config_path()).exists()
|
|
871
|
+
|
|
872
|
+
# For providers not in the wizard's quick-setup list (e.g. ollama-cloud, deepseek,
|
|
873
|
+
# xai, kimi-k2.6), the wizard can never help — it only knows how to configure
|
|
874
|
+
# openrouter/anthropic/openai/google/custom. If such a user has a configured
|
|
875
|
+
# provider + model in config.yaml, showing the wizard would only confuse them
|
|
876
|
+
# (or worse, let them accidentally overwrite their config with gpt-5.4-mini).
|
|
877
|
+
_current_provider = str(
|
|
878
|
+
(cfg.get("model", {}) or {}).get("provider", "") if isinstance(cfg.get("model"), dict)
|
|
879
|
+
else ""
|
|
880
|
+
).strip().lower()
|
|
881
|
+
_is_non_wizard_provider = bool(
|
|
882
|
+
_current_provider and _current_provider not in _SUPPORTED_PROVIDER_SETUPS
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
config_auto_completed = config_exists and (
|
|
886
|
+
bool(runtime.get("chat_ready"))
|
|
887
|
+
or (_is_non_wizard_provider and bool(runtime.get("provider_configured")))
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
# Persist the flag so it survives future transient import failures (e.g. after
|
|
891
|
+
# a git branch switch in the hermes-agent repo). Without this, a CLI-configured
|
|
892
|
+
# user who never ran the wizard has no onboarding_completed flag — any momentary
|
|
893
|
+
# imports_ok=False during restart makes chat_ready=False, config_auto_completed=False,
|
|
894
|
+
# and the wizard reappears with a broken dropdown that clobbers their config.
|
|
895
|
+
#
|
|
896
|
+
# Best-effort: if save_settings raises (read-only FS, disk full, permission error),
|
|
897
|
+
# log and continue. The `config_auto_completed` branch of `completed=` below still
|
|
898
|
+
# returns True for this request, so the user sees the correct state — only the
|
|
899
|
+
# persistence-across-restart guarantee is degraded. Raising here would turn every
|
|
900
|
+
# /api/onboarding/status call into a 500 until disk was writable, which is worse UX
|
|
901
|
+
# than losing the next-restart protection.
|
|
902
|
+
if config_auto_completed and not settings.get("onboarding_completed"):
|
|
903
|
+
try:
|
|
904
|
+
save_settings({"onboarding_completed": True})
|
|
905
|
+
settings["onboarding_completed"] = True
|
|
906
|
+
except Exception:
|
|
907
|
+
logger.debug("Failed to persist onboarding_completed", exc_info=True)
|
|
908
|
+
|
|
909
|
+
return {
|
|
910
|
+
"completed": bool(settings.get("onboarding_completed")) or auto_completed or config_auto_completed,
|
|
911
|
+
"settings": {
|
|
912
|
+
"default_model": settings.get("default_model") or DEFAULT_MODEL,
|
|
913
|
+
"default_workspace": settings.get("default_workspace")
|
|
914
|
+
or str(DEFAULT_WORKSPACE),
|
|
915
|
+
"password_enabled": is_auth_enabled(),
|
|
916
|
+
"bot_name": settings.get("bot_name") or "Hermes",
|
|
917
|
+
},
|
|
918
|
+
"system": {
|
|
919
|
+
"hermes_found": bool(_HERMES_FOUND),
|
|
920
|
+
"imports_ok": bool(imports_ok),
|
|
921
|
+
"missing_modules": missing,
|
|
922
|
+
"import_errors": errors,
|
|
923
|
+
"config_path": str(_get_config_path()),
|
|
924
|
+
"config_exists": Path(_get_config_path()).exists(),
|
|
925
|
+
**runtime,
|
|
926
|
+
},
|
|
927
|
+
"setup": _build_setup_catalog(cfg),
|
|
928
|
+
"workspaces": {
|
|
929
|
+
"items": workspaces,
|
|
930
|
+
"last": last_workspace,
|
|
931
|
+
},
|
|
932
|
+
"models": available_models,
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def apply_onboarding_setup(body: dict) -> dict:
|
|
937
|
+
# Hard guard: if the operator set SKIP_ONBOARDING, the wizard should never
|
|
938
|
+
# have appeared. Even if the frontend somehow calls this endpoint anyway
|
|
939
|
+
# (e.g. a stale JS bundle or a curious user), we must not overwrite the
|
|
940
|
+
# operator's config.yaml or .env files. Just mark onboarding complete and
|
|
941
|
+
# return the current status — no file writes.
|
|
942
|
+
skip_env = os.environ.get("HERMES_WEBUI_SKIP_ONBOARDING", "").strip()
|
|
943
|
+
if skip_env in {"1", "true", "yes"}:
|
|
944
|
+
save_settings({"onboarding_completed": True})
|
|
945
|
+
return get_onboarding_status()
|
|
946
|
+
|
|
947
|
+
provider = str(body.get("provider") or "").strip().lower()
|
|
948
|
+
model = str(body.get("model") or "").strip()
|
|
949
|
+
api_key = str(body.get("api_key") or "").strip()
|
|
950
|
+
base_url = _normalize_base_url(str(body.get("base_url") or ""))
|
|
951
|
+
|
|
952
|
+
if provider not in _SUPPORTED_PROVIDER_SETUPS:
|
|
953
|
+
# Unsupported providers (openai-codex, copilot, nous, etc.) are already
|
|
954
|
+
# configured via the CLI. Just mark onboarding as complete and let the
|
|
955
|
+
# user through — the agent is already set up, no further setup needed.
|
|
956
|
+
save_settings({"onboarding_completed": True})
|
|
957
|
+
return get_onboarding_status()
|
|
958
|
+
if not model:
|
|
959
|
+
raise ValueError("model is required")
|
|
960
|
+
|
|
961
|
+
provider_meta = _SUPPORTED_PROVIDER_SETUPS[provider]
|
|
962
|
+
if provider_meta.get("requires_base_url"):
|
|
963
|
+
if not base_url:
|
|
964
|
+
raise ValueError("base_url is required for custom endpoints")
|
|
965
|
+
parsed = urlparse(base_url)
|
|
966
|
+
if parsed.scheme not in {"http", "https"}:
|
|
967
|
+
raise ValueError("base_url must start with http:// or https://")
|
|
968
|
+
|
|
969
|
+
config_path = _get_config_path()
|
|
970
|
+
# Guard: if config.yaml already exists and the caller did not explicitly
|
|
971
|
+
# acknowledge the overwrite, refuse to proceed. The frontend must pass
|
|
972
|
+
# confirm_overwrite=True after showing the user a confirmation step.
|
|
973
|
+
if Path(config_path).exists() and not body.get("confirm_overwrite"):
|
|
974
|
+
return {
|
|
975
|
+
"error": "config_exists",
|
|
976
|
+
"message": (
|
|
977
|
+
"Hermes is already configured (config.yaml exists). "
|
|
978
|
+
"Pass confirm_overwrite=true to overwrite it."
|
|
979
|
+
),
|
|
980
|
+
"requires_confirm": True,
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
cfg = _load_yaml_config(config_path)
|
|
984
|
+
env_path = _get_active_hermes_home() / ".env"
|
|
985
|
+
env_values = _load_env_file(env_path)
|
|
986
|
+
|
|
987
|
+
if not api_key and not _provider_api_key_present(provider, cfg, env_values):
|
|
988
|
+
# Providers that may run keyless (lmstudio, ollama, custom — gated by
|
|
989
|
+
# `key_optional` in _SUPPORTED_PROVIDER_SETUPS) are allowed to onboard
|
|
990
|
+
# with no api_key. OAuth-capable wizard providers (currently Anthropic
|
|
991
|
+
# via Claude Code) are also allowed once their server-side OAuth/link
|
|
992
|
+
# marker is present.
|
|
993
|
+
oauth_ready = bool(provider_meta.get("oauth_provider")) and _provider_oauth_authenticated(
|
|
994
|
+
str(provider_meta.get("oauth_provider")), _get_active_hermes_home()
|
|
995
|
+
)
|
|
996
|
+
if not provider_meta.get("key_optional") and not oauth_ready:
|
|
997
|
+
raise ValueError(f"{provider_meta['env_var']} is required")
|
|
998
|
+
|
|
999
|
+
model_cfg = cfg.get("model", {})
|
|
1000
|
+
if not isinstance(model_cfg, dict):
|
|
1001
|
+
model_cfg = {}
|
|
1002
|
+
|
|
1003
|
+
model_cfg["provider"] = provider
|
|
1004
|
+
model_cfg["default"] = _normalize_model_for_provider(provider, model)
|
|
1005
|
+
|
|
1006
|
+
if provider_meta.get("requires_base_url"):
|
|
1007
|
+
model_cfg["base_url"] = base_url
|
|
1008
|
+
elif provider_meta.get("default_base_url"):
|
|
1009
|
+
model_cfg["base_url"] = provider_meta["default_base_url"]
|
|
1010
|
+
else:
|
|
1011
|
+
model_cfg.pop("base_url", None)
|
|
1012
|
+
|
|
1013
|
+
cfg["model"] = model_cfg
|
|
1014
|
+
_save_yaml_config(config_path, cfg)
|
|
1015
|
+
|
|
1016
|
+
if api_key:
|
|
1017
|
+
_write_env_file(env_path, {provider_meta["env_var"]: api_key})
|
|
1018
|
+
|
|
1019
|
+
# Reload the hermes_cli provider/config cache so the next streaming call
|
|
1020
|
+
# picks up the new key without requiring a server restart.
|
|
1021
|
+
try:
|
|
1022
|
+
from api.profiles import _reload_dotenv
|
|
1023
|
+
_reload_dotenv(_get_active_hermes_home())
|
|
1024
|
+
except Exception:
|
|
1025
|
+
logger.debug("Failed to reload dotenv")
|
|
1026
|
+
|
|
1027
|
+
# Belt-and-braces: set directly on os.environ AFTER _reload_dotenv so the
|
|
1028
|
+
# value survives even if _reload_dotenv cleared it (e.g. when _write_env_file
|
|
1029
|
+
# wrote to disk but the profile isolation tracking hasn't seen it yet).
|
|
1030
|
+
if api_key:
|
|
1031
|
+
os.environ[provider_meta["env_var"]] = api_key
|
|
1032
|
+
|
|
1033
|
+
try:
|
|
1034
|
+
# hermes_cli may cache config at import time; ask it to reload if possible.
|
|
1035
|
+
from hermes_cli.config import reload as _cli_reload
|
|
1036
|
+
_cli_reload()
|
|
1037
|
+
except Exception:
|
|
1038
|
+
logger.debug("Failed to reload hermes_cli config")
|
|
1039
|
+
|
|
1040
|
+
reload_config()
|
|
1041
|
+
return get_onboarding_status()
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def complete_onboarding() -> dict:
|
|
1045
|
+
save_settings({"onboarding_completed": True})
|
|
1046
|
+
return get_onboarding_status()
|