@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.
Files changed (233) hide show
  1. package/README.md +213 -0
  2. package/bin/hermes-webui.mjs +588 -0
  3. package/package.json +25 -0
  4. package/scripts/sync-vendor.mjs +74 -0
  5. package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
  6. package/templates/systemd/hermes-webui.service +13 -0
  7. package/templates/windows/hermes-webui-task.ps1 +3 -0
  8. package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
  9. package/vendor/agent-frontend-shell/.dockerignore +7 -0
  10. package/vendor/agent-frontend-shell/.env.docker.example +89 -0
  11. package/vendor/agent-frontend-shell/.env.example +34 -0
  12. package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
  13. package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
  14. package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
  15. package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
  16. package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
  17. package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
  18. package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
  19. package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
  20. package/vendor/agent-frontend-shell/AGENTS.md +80 -0
  21. package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
  22. package/vendor/agent-frontend-shell/BUGS.md +52 -0
  23. package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
  24. package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
  25. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
  26. package/vendor/agent-frontend-shell/DESIGN.md +173 -0
  27. package/vendor/agent-frontend-shell/Dockerfile +91 -0
  28. package/vendor/agent-frontend-shell/LICENSE +21 -0
  29. package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
  30. package/vendor/agent-frontend-shell/README.md +705 -0
  31. package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
  32. package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
  33. package/vendor/agent-frontend-shell/TESTING.md +1932 -0
  34. package/vendor/agent-frontend-shell/THEMES.md +170 -0
  35. package/vendor/agent-frontend-shell/api/__init__.py +1 -0
  36. package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
  37. package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
  38. package/vendor/agent-frontend-shell/api/auth.py +592 -0
  39. package/vendor/agent-frontend-shell/api/background.py +87 -0
  40. package/vendor/agent-frontend-shell/api/clarify.py +238 -0
  41. package/vendor/agent-frontend-shell/api/commands.py +124 -0
  42. package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
  43. package/vendor/agent-frontend-shell/api/config.py +5178 -0
  44. package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
  45. package/vendor/agent-frontend-shell/api/extensions.py +253 -0
  46. package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
  47. package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
  48. package/vendor/agent-frontend-shell/api/goals.py +608 -0
  49. package/vendor/agent-frontend-shell/api/helpers.py +474 -0
  50. package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
  51. package/vendor/agent-frontend-shell/api/metering.py +194 -0
  52. package/vendor/agent-frontend-shell/api/models.py +4210 -0
  53. package/vendor/agent-frontend-shell/api/oauth.py +770 -0
  54. package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
  55. package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
  56. package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
  57. package/vendor/agent-frontend-shell/api/providers.py +2175 -0
  58. package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
  59. package/vendor/agent-frontend-shell/api/rollback.py +320 -0
  60. package/vendor/agent-frontend-shell/api/routes.py +13990 -0
  61. package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
  62. package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
  63. package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
  64. package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
  65. package/vendor/agent-frontend-shell/api/session_events.py +45 -0
  66. package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
  67. package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
  68. package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
  69. package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
  70. package/vendor/agent-frontend-shell/api/startup.py +128 -0
  71. package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
  72. package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
  73. package/vendor/agent-frontend-shell/api/system_health.py +167 -0
  74. package/vendor/agent-frontend-shell/api/terminal.py +410 -0
  75. package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
  76. package/vendor/agent-frontend-shell/api/updates.py +1261 -0
  77. package/vendor/agent-frontend-shell/api/upload.py +322 -0
  78. package/vendor/agent-frontend-shell/api/usage.py +26 -0
  79. package/vendor/agent-frontend-shell/api/workspace.py +867 -0
  80. package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
  81. package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
  82. package/vendor/agent-frontend-shell/bootstrap.py +492 -0
  83. package/vendor/agent-frontend-shell/ctl.sh +427 -0
  84. package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
  85. package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
  86. package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
  87. package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
  88. package/vendor/agent-frontend-shell/docker_init.bash +459 -0
  89. package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
  90. package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
  91. package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
  92. package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
  93. package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
  94. package/vendor/agent-frontend-shell/docs/docker.md +337 -0
  95. package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
  96. package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
  97. package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
  98. package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
  99. package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
  100. package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
  101. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
  102. package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
  103. package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
  104. package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
  105. package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
  106. package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
  107. package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
  108. package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
  109. package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
  110. package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
  111. package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
  112. package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
  113. package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
  114. package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
  115. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
  116. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
  117. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
  118. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
  119. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
  120. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
  121. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
  122. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
  123. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
  124. package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
  125. package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
  126. package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
  127. package/vendor/agent-frontend-shell/extensions/index.js +67 -0
  128. package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
  129. package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
  130. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
  131. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
  132. package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
  133. package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
  134. package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
  135. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
  136. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
  137. package/vendor/agent-frontend-shell/install.sh +63 -0
  138. package/vendor/agent-frontend-shell/mcp_server.py +567 -0
  139. package/vendor/agent-frontend-shell/package.json +12 -0
  140. package/vendor/agent-frontend-shell/pyproject.toml +56 -0
  141. package/vendor/agent-frontend-shell/pytest.ini +3 -0
  142. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  143. package/vendor/agent-frontend-shell/server.py +624 -0
  144. package/vendor/agent-frontend-shell/start.ps1 +210 -0
  145. package/vendor/agent-frontend-shell/start.sh +65 -0
  146. package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
  147. package/vendor/agent-frontend-shell/static/boot.js +1990 -0
  148. package/vendor/agent-frontend-shell/static/commands.js +1402 -0
  149. package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
  150. package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
  151. package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
  152. package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
  153. package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
  154. package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
  155. package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
  156. package/vendor/agent-frontend-shell/static/icons.js +92 -0
  157. package/vendor/agent-frontend-shell/static/index.html +1506 -0
  158. package/vendor/agent-frontend-shell/static/login.js +177 -0
  159. package/vendor/agent-frontend-shell/static/manifest.json +53 -0
  160. package/vendor/agent-frontend-shell/static/messages.js +3521 -0
  161. package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
  162. package/vendor/agent-frontend-shell/static/panels.js +7995 -0
  163. package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
  164. package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
  165. package/vendor/agent-frontend-shell/static/style.css +4774 -0
  166. package/vendor/agent-frontend-shell/static/sw.js +173 -0
  167. package/vendor/agent-frontend-shell/static/terminal.js +632 -0
  168. package/vendor/agent-frontend-shell/static/ui.js +8997 -0
  169. package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
  170. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
  171. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
  172. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  173. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  174. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  175. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  176. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  177. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  178. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  179. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  180. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  181. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  182. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  183. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  184. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  185. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
  186. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
  187. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
  188. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  189. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  190. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  191. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
  192. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
  193. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
  194. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
  195. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
  196. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
  197. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  198. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  199. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  200. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
  201. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
  202. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
  203. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  204. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  205. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  206. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  207. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  208. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  209. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  210. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  211. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  212. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
  213. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
  214. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
  215. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
  216. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
  217. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  218. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
  219. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
  220. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  221. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
  222. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
  223. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  224. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
  225. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
  226. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  227. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  228. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  229. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  230. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
  231. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
  232. package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
  233. 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()