@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,2175 @@
1
+ """Hermes Web UI -- provider management endpoints.
2
+
3
+ Provides CRUD operations for configuring provider API keys post-onboarding.
4
+ Closes #586 (allow provider key update) and part of #604 (model picker
5
+ multi-provider support).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ import os
15
+ import signal
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ import time
20
+ import urllib.error
21
+ import urllib.request
22
+ from contextlib import contextmanager, nullcontext
23
+ from datetime import datetime, timedelta, timezone
24
+ from pathlib import Path
25
+ from types import SimpleNamespace
26
+ from typing import Any
27
+
28
+ try: # POSIX-only; Windows-style environments fall back to process-local locking.
29
+ import fcntl
30
+ except ImportError: # pragma: no cover - exercised only where fcntl is unavailable
31
+ fcntl = None # type: ignore[assignment]
32
+
33
+ from api.config import (
34
+ _PROVIDER_DISPLAY,
35
+ _PROVIDER_MODELS,
36
+ _custom_provider_slug_from_name,
37
+ _get_label_for_model,
38
+ _models_from_live_provider_ids,
39
+ _read_live_provider_model_ids,
40
+ _read_visible_codex_cache_model_ids,
41
+ _save_yaml_config_file,
42
+ get_config,
43
+ invalidate_models_cache,
44
+ reload_config,
45
+ )
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ def _custom_provider_name_matches(provider_id: str, name: object) -> bool:
51
+ """Return True when *provider_id* refers to a named custom provider."""
52
+ pid = str(provider_id or "").strip().lower()
53
+ raw_name = str(name or "").strip().lower()
54
+ if not pid or not raw_name:
55
+ return False
56
+ slug = _custom_provider_slug_from_name(raw_name)
57
+ candidates = {raw_name, f"custom:{raw_name}"}
58
+ if slug:
59
+ candidates.add(slug)
60
+ return pid in candidates
61
+
62
+ _OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key"
63
+ _PROVIDER_QUOTA_TIMEOUT_SECONDS = 3.0
64
+ _ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS = 35.0
65
+ _ACCOUNT_USAGE_CACHE_TTL_SECONDS = 45.0
66
+ _ACCOUNT_USAGE_CACHE_MAX_ENTRIES = 64
67
+ _ACCOUNT_USAGE_PROVIDERS = frozenset({"openai-codex", "anthropic"})
68
+
69
+ # Upper bound on simultaneous profile-isolated quota probe subprocesses.
70
+ # Each probe runs a Python child for up to 35 s; capping concurrency prevents
71
+ # resource exhaustion when the UI polls all providers rapidly. The limit is
72
+ # deliberately low (2) since _ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS is
73
+ # already 35 s and probe I/O is lightweight HTTP calls.
74
+ _MAX_CONCURRENT_ACCOUNT_USAGE_PROBES = 2
75
+
76
+ # Parent-death-signal setup: on Linux, arrange for the quota-probe child to
77
+ # receive SIGTERM when the WebUI parent dies (e.g. systemctl restart, OOM kill).
78
+ # This prevents probe children from becoming orphaned zombies that continue
79
+ # calling the provider API indefinitely after the WebUI process is gone.
80
+ # We use prctl(PR_SET_DEATHSIG, SIGTERM) which is standard on modern Linux
81
+ # kernels and available via ctypes (no external C extension needed).
82
+ # If prctl is unavailable (non-Linux, or Linux without prctl support), the
83
+ # probe child exits normally when its parent (WebUI) terminates -- on macOS/
84
+ # Windows this is handled by OS-level process tree cleanup.
85
+ # Portable parent-death-signal bootstrap. On Linux this arranges for the
86
+ # probe child to receive SIGTERM when the WebUI parent dies (systemctl
87
+ # restart, OOM kill, etc.), preventing orphaned zombie probes from continuing
88
+ # to call the provider API indefinitely. Non-Linux platforms (macOS, Windows)
89
+ # rely on OS-level process-tree cleanup instead; this variable is then unused.
90
+ # prctl(PR_SET_DEATHSIG, SIGTERM) is available via ctypes without any C
91
+ # extension — the same technique used throughout the Hermes codebase.
92
+ _ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP = (
93
+ # fmt: off
94
+ # Lines are written as string literals so this block passes
95
+ # `python3 -m py_compile` cleanly and is safe to include verbatim
96
+ # inside the single argument string passed to `python -c ...`.
97
+ 'import sys\n'
98
+ 'try:\n'
99
+ ' import ctypes, signal\n'
100
+ ' libc = ctypes.CDLL(None)\n'
101
+ ' libc.prctl(1, signal.SIGTERM) # PR_SET_DEATHSIG=1, SIGTERM=15\n'
102
+ 'except Exception:\n'
103
+ ' pass\n'
104
+ # fmt: on
105
+ )
106
+
107
+
108
+ # Module-level cap on concurrent quota-probe subprocesses.
109
+ # Lazily created so this module compiles even when threading isn't ready.
110
+ _account_usage_probe_semaphore: threading.BoundedSemaphore | None = None
111
+
112
+ # Short-lived account-usage cache. The Codex pooled probe may check multiple
113
+ # credentials, so cache sanitized snapshots briefly to avoid re-querying the
114
+ # provider on every Settings repaint/profile-panel refresh. Pool composition
115
+ # changes can be stale for at most this TTL; that is preferred to hammering the
116
+ # provider usage API while the Settings panel is open. Transient None probe
117
+ # results are intentionally not cached; known exhausted/unavailable states are
118
+ # represented as non-None snapshots and remain cacheable.
119
+ _account_usage_status_cache: dict[tuple[str, str, str], tuple[float, Any]] = {}
120
+ _account_usage_status_cache_lock = threading.Lock()
121
+
122
+
123
+ def _get_account_usage_probe_semaphore() -> threading.BoundedSemaphore:
124
+ global _account_usage_probe_semaphore
125
+ if _account_usage_probe_semaphore is None:
126
+ _account_usage_probe_semaphore = threading.BoundedSemaphore(
127
+ _MAX_CONCURRENT_ACCOUNT_USAGE_PROBES
128
+ )
129
+ return _account_usage_probe_semaphore
130
+
131
+
132
+ # ── preexec_fn: parent-death signal for the probe subprocess ─────────────────
133
+ # On POSIX/Linux, arrange for the child to receive SIGTERM when the WebUI
134
+ # parent dies (systemctl restart, OOM kill, etc.). The parent's bootstrap
135
+ # code (_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP) also covers the grandchild
136
+ # fork inside the child, but this preexec_fn handles the direct child-process
137
+ # case. Returns None on non-POSIX or when prctl is unavailable so that
138
+ # subprocess.run() works on Windows/macOS without changes.
139
+ def _account_usage_preexec_fn() -> None:
140
+ try:
141
+ import ctypes
142
+ libc = ctypes.CDLL(None)
143
+ libc.prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG=1, SIGTERM=15
144
+ except Exception:
145
+ pass
146
+
147
+
148
+ _ACCOUNT_USAGE_SUBPROCESS_CODE = r"""
149
+ import base64
150
+ import json
151
+ import sys
152
+ from concurrent.futures import ThreadPoolExecutor
153
+ from datetime import datetime, timedelta, timezone
154
+ from types import SimpleNamespace
155
+ from urllib import request as urllib_request
156
+
157
+ from agent.account_usage import fetch_account_usage
158
+
159
+
160
+ _CODEX_DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex"
161
+ _CODEX_POOL_USAGE_TIMEOUT_SECONDS = 4.0
162
+ _CODEX_POOL_MAX_WORKERS = 6
163
+
164
+
165
+ def _iso(value):
166
+ if value in (None, ""):
167
+ return None
168
+ if hasattr(value, "isoformat"):
169
+ text = value.isoformat()
170
+ return text.replace("+00:00", "Z")
171
+ text = str(value).strip()
172
+ return text or None
173
+
174
+
175
+ def _snapshot_payload(snapshot):
176
+ if snapshot is None:
177
+ return None
178
+ windows = []
179
+ for window in getattr(snapshot, "windows", ()) or ():
180
+ windows.append({
181
+ "label": str(getattr(window, "label", "") or ""),
182
+ "used_percent": getattr(window, "used_percent", None),
183
+ "reset_at": _iso(getattr(window, "reset_at", None)),
184
+ "detail": getattr(window, "detail", None),
185
+ })
186
+ payload = {
187
+ "provider": str(getattr(snapshot, "provider", "") or ""),
188
+ "source": str(getattr(snapshot, "source", "") or ""),
189
+ "title": str(getattr(snapshot, "title", "") or ""),
190
+ "plan": getattr(snapshot, "plan", None),
191
+ "windows": windows,
192
+ "details": list(getattr(snapshot, "details", ()) or ()),
193
+ "available": bool(getattr(snapshot, "available", bool(windows))),
194
+ "unavailable_reason": getattr(snapshot, "unavailable_reason", None),
195
+ "fetched_at": _iso(getattr(snapshot, "fetched_at", None)),
196
+ }
197
+ pool = getattr(snapshot, "pool", None)
198
+ if isinstance(pool, dict):
199
+ payload["pool"] = pool
200
+ return payload
201
+
202
+
203
+ def _snapshot_available(snapshot):
204
+ if snapshot is None:
205
+ return False
206
+ try:
207
+ return bool(getattr(snapshot, "available", False))
208
+ except Exception:
209
+ return False
210
+
211
+
212
+ def _number(value):
213
+ if isinstance(value, bool) or value is None:
214
+ return None
215
+ if isinstance(value, (int, float)):
216
+ return value
217
+ try:
218
+ text = str(value).strip()
219
+ if not text:
220
+ return None
221
+ number = float(text)
222
+ return int(number) if number.is_integer() else number
223
+ except Exception:
224
+ return None
225
+
226
+
227
+ def _parse_dt(value):
228
+ if value in (None, ""):
229
+ return None
230
+ if isinstance(value, (int, float)):
231
+ try:
232
+ return datetime.fromtimestamp(float(value), tz=timezone.utc)
233
+ except Exception:
234
+ return None
235
+ text = str(value).strip()
236
+ if not text:
237
+ return None
238
+ if text.endswith("Z"):
239
+ text = text[:-1] + "+00:00"
240
+ try:
241
+ dt = datetime.fromisoformat(text)
242
+ except ValueError:
243
+ return None
244
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
245
+
246
+
247
+ def _title_case_slug(value):
248
+ cleaned = str(value or "").strip()
249
+ if not cleaned:
250
+ return None
251
+ return cleaned.replace("_", " ").replace("-", " ").title()
252
+
253
+
254
+ def _resolve_codex_usage_url(base_url):
255
+ normalized = str(base_url or "").strip().rstrip("/") or _CODEX_DEFAULT_BASE_URL
256
+ if normalized.endswith("/codex"):
257
+ normalized = normalized[: -len("/codex")]
258
+ if "/backend-api" in normalized:
259
+ return normalized + "/wham/usage"
260
+ return normalized + "/api/codex/usage"
261
+
262
+
263
+ def _jwt_claims(token):
264
+ if not isinstance(token, str) or token.count(".") != 2:
265
+ return {}
266
+ payload = token.split(".")[1]
267
+ payload += "=" * ((4 - len(payload) % 4) % 4)
268
+ try:
269
+ claims = json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8"))
270
+ except Exception:
271
+ return {}
272
+ return claims if isinstance(claims, dict) else {}
273
+
274
+
275
+ def _codex_usage_headers(access_token):
276
+ headers = {
277
+ "Authorization": "Bearer " + access_token,
278
+ "Accept": "application/json",
279
+ "User-Agent": "codex_cli_rs/0.0.0 (Hermes WebUI)",
280
+ "originator": "codex_cli_rs",
281
+ }
282
+ auth_claim = _jwt_claims(access_token).get("https://api.openai.com/auth")
283
+ account_id = None
284
+ if isinstance(auth_claim, dict):
285
+ account_id = auth_claim.get("chatgpt_account_id")
286
+ if isinstance(account_id, str) and account_id.strip():
287
+ headers["ChatGPT-Account-ID"] = account_id.strip()
288
+ return headers
289
+
290
+
291
+ def _entry_value(entry, *names):
292
+ for name in names:
293
+ try:
294
+ value = getattr(entry, name)
295
+ except Exception:
296
+ value = None
297
+ if value in (None, ""):
298
+ continue
299
+ text = str(value).strip()
300
+ if text:
301
+ return text
302
+ return None
303
+
304
+
305
+ def _codex_snapshot_from_usage_payload(payload):
306
+ if not isinstance(payload, dict):
307
+ payload = {}
308
+ rate_limit = payload.get("rate_limit")
309
+ if not isinstance(rate_limit, dict):
310
+ rate_limit = {}
311
+ windows = []
312
+ for key, label in (("primary_window", "Session"), ("secondary_window", "Weekly")):
313
+ window = rate_limit.get(key)
314
+ if not isinstance(window, dict):
315
+ continue
316
+ used = _number(window.get("used_percent"))
317
+ if used is None:
318
+ continue
319
+ windows.append(SimpleNamespace(
320
+ label=label,
321
+ used_percent=float(used),
322
+ reset_at=_parse_dt(window.get("reset_at")),
323
+ detail=None,
324
+ ))
325
+
326
+ details = []
327
+ credits = payload.get("credits")
328
+ if isinstance(credits, dict) and credits.get("has_credits"):
329
+ balance = _number(credits.get("balance"))
330
+ if balance is not None:
331
+ details.append("Credits balance: $" + format(float(balance), ".2f"))
332
+ elif credits.get("unlimited"):
333
+ details.append("Credits balance: unlimited")
334
+
335
+ return SimpleNamespace(
336
+ provider="openai-codex",
337
+ source="usage_api",
338
+ title="Account limits",
339
+ plan=_title_case_slug(payload.get("plan_type")),
340
+ windows=tuple(windows),
341
+ details=tuple(details),
342
+ available=bool(windows or details),
343
+ unavailable_reason=None,
344
+ fetched_at=datetime.now(timezone.utc),
345
+ )
346
+
347
+
348
+ def _snapshot_windows_payload(snapshot):
349
+ windows = []
350
+ for window in getattr(snapshot, "windows", ()) or ():
351
+ label = str(getattr(window, "label", "") or "").strip()
352
+ if not label:
353
+ continue
354
+ used_percent = _number(getattr(window, "used_percent", None))
355
+ remaining_percent = None
356
+ if used_percent is not None:
357
+ remaining_percent = max(0.0, min(100.0, 100.0 - float(used_percent)))
358
+ windows.append({
359
+ "label": label,
360
+ "used_percent": used_percent,
361
+ "remaining_percent": remaining_percent,
362
+ "reset_at": _iso(getattr(window, "reset_at", None)),
363
+ "detail": getattr(window, "detail", None),
364
+ })
365
+ return windows
366
+
367
+
368
+ def _snapshot_details_payload(snapshot):
369
+ return [
370
+ str(detail).strip()
371
+ for detail in (getattr(snapshot, "details", ()) or ())
372
+ if str(detail).strip()
373
+ ]
374
+
375
+
376
+ def _safe_entry_label(entry, index):
377
+ label = _entry_value(entry, "label", "source") or ""
378
+ if not label:
379
+ label = "Credential " + str(index)
380
+ label = " ".join(str(label).split())
381
+ if len(label) > 64:
382
+ label = label[:61].rstrip() + "..."
383
+ return label
384
+
385
+
386
+ def _safe_unavailable_reason(reason):
387
+ text = " ".join(str(reason or "").split())
388
+ if not text:
389
+ return None
390
+ lowered = text.lower()
391
+ sensitive_terms = ("access_token", "refresh_token", "authorization", "bearer ", "jwt", "secret")
392
+ if any(term in lowered for term in sensitive_terms):
393
+ return "Usage unavailable for this credential."
394
+ return text[:180]
395
+
396
+
397
+ def _entry_exhausted_ttl_seconds(error_code):
398
+ code = str(error_code or "").strip()
399
+ if code == "401":
400
+ return 5 * 60
401
+ return 60 * 60
402
+
403
+
404
+ def _entry_pool_exhausted_until(entry):
405
+ if str(_entry_value(entry, "last_status") or "").strip().lower() != "exhausted":
406
+ return None
407
+ reset_at = _parse_dt(getattr(entry, "last_error_reset_at", None))
408
+ if reset_at is not None:
409
+ return reset_at
410
+ status_at = _parse_dt(getattr(entry, "last_status_at", None))
411
+ if status_at is None:
412
+ return None
413
+ return status_at + timedelta(seconds=_entry_exhausted_ttl_seconds(_entry_value(entry, "last_error_code")))
414
+
415
+
416
+ def _entry_is_pool_exhausted(entry):
417
+ exhausted_until = _entry_pool_exhausted_until(entry)
418
+ return exhausted_until is not None and datetime.now(timezone.utc) < exhausted_until
419
+
420
+
421
+ def _entry_pool_exhausted_reason(entry):
422
+ code = _entry_value(entry, "last_error_code")
423
+ reset_at = _entry_pool_retry_after(entry)
424
+ reason = "Credential pool marked this credential exhausted"
425
+ if code:
426
+ reason += " after provider status " + code
427
+ if reset_at:
428
+ reason += "; retry after " + reset_at
429
+ return reason + "."
430
+
431
+
432
+ def _entry_pool_retry_after(entry):
433
+ return _iso(_entry_pool_exhausted_until(entry))
434
+
435
+
436
+ def _fetch_codex_entry_snapshot(entry):
437
+ access_token = _entry_value(entry, "runtime_api_key", "access_token")
438
+ if not access_token:
439
+ return None, False, "No runtime token available."
440
+ base_url = _entry_value(entry, "runtime_base_url", "base_url") or _CODEX_DEFAULT_BASE_URL
441
+ request = urllib_request.Request(
442
+ _resolve_codex_usage_url(base_url),
443
+ headers=_codex_usage_headers(access_token),
444
+ )
445
+ with urllib_request.urlopen(request, timeout=_CODEX_POOL_USAGE_TIMEOUT_SECONDS) as response:
446
+ payload = json.loads(response.read().decode("utf-8") or "{}")
447
+ return _codex_snapshot_from_usage_payload(payload), True, None
448
+
449
+
450
+ def _best_remaining_by_window(rows):
451
+ best = {}
452
+ for row in rows:
453
+ if row.get("status") != "available":
454
+ continue
455
+ label = row.get("label") or "Credential"
456
+ for window in row.get("windows") or []:
457
+ if not isinstance(window, dict):
458
+ continue
459
+ window_label = str(window.get("label") or "").strip()
460
+ remaining = _number(window.get("remaining_percent"))
461
+ if not window_label or remaining is None:
462
+ continue
463
+ candidate = {
464
+ "label": window_label,
465
+ "remaining_percent": remaining,
466
+ "used_percent": window.get("used_percent"),
467
+ "reset_at": window.get("reset_at"),
468
+ "detail": window.get("detail"),
469
+ "credential_label": label,
470
+ }
471
+ current = best.get(window_label.lower())
472
+ # The normalized Codex account-limit payload currently exposes
473
+ # percentages, not absolute request/token capacity. If absolute
474
+ # remaining capacity becomes available, prefer it here.
475
+ if current is None or float(remaining) > float(current.get("remaining_percent") or -1):
476
+ best[window_label.lower()] = candidate
477
+ return list(best.values())
478
+
479
+
480
+ def _next_reset_at(rows):
481
+ best_dt = None
482
+ best_text = None
483
+ for row in rows:
484
+ for window in row.get("windows") or []:
485
+ if not isinstance(window, dict):
486
+ continue
487
+ reset_text = window.get("reset_at")
488
+ dt = _parse_dt(reset_text)
489
+ if dt is None:
490
+ continue
491
+ if best_dt is None or dt < best_dt:
492
+ best_dt = dt
493
+ best_text = _iso(dt)
494
+ return best_text
495
+
496
+
497
+ def _codex_pool_snapshot(entries, rows, queried):
498
+ available_rows = [row for row in rows if row.get("status") == "available"]
499
+ exhausted_rows = [row for row in rows if row.get("status") == "exhausted"]
500
+ failed_rows = [row for row in rows if row.get("status") not in {"available", "exhausted"}]
501
+ plans = []
502
+ for row in rows:
503
+ plan = row.get("plan")
504
+ if plan and plan not in plans:
505
+ plans.append(plan)
506
+ best_windows = _best_remaining_by_window(rows)
507
+ pool = {
508
+ "total_credentials": len(entries),
509
+ "queried_credentials": queried,
510
+ "available_credentials": len(available_rows),
511
+ "exhausted_credentials": len(exhausted_rows),
512
+ "failed_credentials": len(failed_rows),
513
+ "plans": plans,
514
+ "next_reset_at": _next_reset_at(rows),
515
+ "best_remaining_by_window": best_windows,
516
+ "credentials": rows,
517
+ }
518
+ details = [str(len(available_rows)) + "/" + str(len(entries)) + " credentials available"]
519
+ if exhausted_rows:
520
+ details.append(str(len(exhausted_rows)) + " exhausted")
521
+ if failed_rows:
522
+ details.append(str(len(failed_rows)) + " failed to load")
523
+ if plans:
524
+ details.append("Plans: " + ", ".join(plans))
525
+ plan = plans[0] if len(plans) == 1 else None
526
+ windows = tuple(
527
+ SimpleNamespace(
528
+ label=window.get("label"),
529
+ used_percent=window.get("used_percent"),
530
+ reset_at=window.get("reset_at"),
531
+ detail="Best of " + str(len(available_rows)) + " available credentials",
532
+ )
533
+ for window in best_windows
534
+ )
535
+ return SimpleNamespace(
536
+ provider="openai-codex",
537
+ source="usage_api_pool",
538
+ title="Account limits",
539
+ plan=plan,
540
+ windows=windows,
541
+ details=tuple(details),
542
+ available=bool(available_rows),
543
+ unavailable_reason=None if available_rows else "No Codex pool credentials returned available account limits.",
544
+ fetched_at=datetime.now(timezone.utc),
545
+ pool=pool,
546
+ )
547
+
548
+
549
+ def _codex_pool_exhausted_row(entry, index):
550
+ label = _safe_entry_label(entry, index)
551
+ retry_after = _entry_pool_retry_after(entry)
552
+ return {
553
+ "label": label,
554
+ "status": "exhausted",
555
+ "plan": None,
556
+ "windows": [],
557
+ "details": [],
558
+ "unavailable_reason": _entry_pool_exhausted_reason(entry),
559
+ "retry_after": retry_after,
560
+ "fetched_at": None,
561
+ }
562
+
563
+
564
+ def _probe_codex_pool_entry(item):
565
+ index, entry = item
566
+ label = _safe_entry_label(entry, index)
567
+ did_query_count = 0
568
+ try:
569
+ snapshot, did_query, reason = _fetch_codex_entry_snapshot(entry)
570
+ if did_query:
571
+ did_query_count = 1
572
+ except Exception as exc:
573
+ snapshot = None
574
+ reason = str(exc)
575
+ windows = _snapshot_windows_payload(snapshot) if snapshot is not None else []
576
+ details = _snapshot_details_payload(snapshot) if snapshot is not None else []
577
+ snapshot_available = _snapshot_available(snapshot)
578
+ status = "available" if snapshot_available else "unavailable"
579
+ row = {
580
+ "label": label,
581
+ "status": status,
582
+ "plan": getattr(snapshot, "plan", None) if snapshot is not None else None,
583
+ "windows": windows,
584
+ "details": details,
585
+ "unavailable_reason": None if snapshot_available else _safe_unavailable_reason(reason or getattr(snapshot, "unavailable_reason", None)),
586
+ "fetched_at": _iso(getattr(snapshot, "fetched_at", None)) if snapshot is not None else None,
587
+ }
588
+ return index, row, did_query_count
589
+
590
+
591
+ def _fetch_codex_account_usage_from_pool():
592
+ try:
593
+ from agent.credential_pool import load_pool
594
+
595
+ pool = load_pool("openai-codex")
596
+ entries = list(pool.entries()) if pool is not None and hasattr(pool, "entries") else []
597
+ if not entries:
598
+ return None
599
+ rows_by_index = {}
600
+ probe_items = []
601
+ queried = 0
602
+ for index, entry in enumerate(entries, start=1):
603
+ if _entry_is_pool_exhausted(entry):
604
+ rows_by_index[index] = _codex_pool_exhausted_row(entry, index)
605
+ else:
606
+ probe_items.append((index, entry))
607
+ if probe_items:
608
+ max_workers = min(_CODEX_POOL_MAX_WORKERS, len(probe_items))
609
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
610
+ for index, row, did_query_count in executor.map(_probe_codex_pool_entry, probe_items):
611
+ rows_by_index[index] = row
612
+ queried += did_query_count
613
+ rows = [rows_by_index[index] for index in range(1, len(entries) + 1)]
614
+ return _codex_pool_snapshot(entries, rows, queried)
615
+ except Exception:
616
+ return None
617
+
618
+
619
+ provider = sys.argv[1]
620
+ api_key = sys.argv[2] or None
621
+ try:
622
+ snapshot = fetch_account_usage(provider, api_key=api_key)
623
+ except Exception:
624
+ snapshot = None
625
+ if str(provider or "").strip().lower() == "openai-codex":
626
+ pool_snapshot = _fetch_codex_account_usage_from_pool()
627
+ if isinstance(getattr(pool_snapshot, "pool", None), dict):
628
+ snapshot = pool_snapshot
629
+ print(json.dumps(_snapshot_payload(snapshot)))
630
+ """
631
+
632
+ # SECTION: Provider ↔ env var mapping
633
+
634
+ # Maps canonical provider slug → env var name for API key.
635
+ # Providers not listed here (OAuth/token-flow providers like copilot, nous,
636
+ # openai-codex) cannot have their keys managed from the WebUI.
637
+ _PROVIDER_ENV_VAR: dict[str, str] = {
638
+ "openrouter": "OPENROUTER_API_KEY",
639
+ "anthropic": "ANTHROPIC_API_KEY",
640
+ "openai": "OPENAI_API_KEY",
641
+ "google": "GOOGLE_API_KEY",
642
+ "gemini": "GEMINI_API_KEY",
643
+ "zai": "GLM_API_KEY",
644
+ "kimi-coding": "KIMI_API_KEY",
645
+ "deepseek": "DEEPSEEK_API_KEY",
646
+ "minimax": "MINIMAX_API_KEY",
647
+ "minimax-cn": "MINIMAX_CN_API_KEY",
648
+ "mistralai": "MISTRAL_API_KEY",
649
+ "x-ai": "XAI_API_KEY",
650
+ "xiaomi": "XIAOMI_API_KEY",
651
+ "opencode-zen": "OPENCODE_ZEN_API_KEY",
652
+ "opencode-go": "OPENCODE_GO_API_KEY",
653
+ # NOTE: bare "ollama" (local) deliberately omitted — local Ollama is keyless
654
+ # by default and the runtime in hermes_cli/runtime_provider.py only consumes
655
+ # OLLAMA_API_KEY when the base URL hostname is ollama.com (Ollama Cloud).
656
+ # If we mapped both providers to the same env var, configuring Ollama Cloud
657
+ # would falsely flip the local Ollama card to "API key configured" (#1410).
658
+ # Users who genuinely run an authenticated local Ollama can still set a key
659
+ # via providers.ollama.api_key in config.yaml — that path remains supported
660
+ # by _provider_has_key().
661
+ "ollama-cloud": "OLLAMA_API_KEY",
662
+ # Bare "lmstudio" maps to LM_API_KEY — the canonical env var the agent CLI
663
+ # runtime reads (hermes_cli/auth.py:182, api_key_env_vars=("LM_API_KEY",)).
664
+ # Pre-#1499/#1500 the WebUI used LMSTUDIO_API_KEY here, which made Settings
665
+ # report keys correctly but the agent runtime ignored them — masked in
666
+ # practice by the LMSTUDIO_NOAUTH_PLACEHOLDER for keyless local installs.
667
+ # Aligning to LM_API_KEY makes a configured LM Studio key actually work
668
+ # for chat. The legacy LMSTUDIO_API_KEY name is read by `_provider_has_key`
669
+ # via _PROVIDER_ENV_VAR_ALIASES below so existing users don't see Settings
670
+ # flip to "no key" after upgrading.
671
+ "lmstudio": "LM_API_KEY",
672
+ "nvidia": "NVIDIA_API_KEY",
673
+ }
674
+
675
+ # Read-only legacy env-var aliases. When `_provider_has_key(pid)` looks up its
676
+ # canonical env var name and finds nothing, it also checks any aliases listed
677
+ # here. Onboarding (api/onboarding.py:apply_onboarding_setup) only writes the
678
+ # canonical name. Use this for env vars that were renamed in a past release;
679
+ # add an entry, ship for a few releases, then remove the alias once enough
680
+ # users have upgraded.
681
+ _PROVIDER_ENV_VAR_ALIASES: dict[str, tuple[str, ...]] = {
682
+ # #1500 — agent runtime reads LM_API_KEY (canonical), but WebUI builds
683
+ # ≤ v0.50.272 wrote LMSTUDIO_API_KEY into .env. Keep reading both.
684
+ "lmstudio": ("LMSTUDIO_API_KEY",),
685
+ # #3145 — provider detection treats OPENCODE_API_KEY as enabling both
686
+ # OpenCode Zen and OpenCode Go. The runtime-facing lookup must read the same
687
+ # shared bridge key after the provider-specific slot, otherwise Settings can
688
+ # show the groups as configured while chat fails the no-key path.
689
+ "opencode-zen": ("OPENCODE_API_KEY",),
690
+ "opencode-go": ("OPENCODE_API_KEY",),
691
+ }
692
+
693
+ # Providers that use OAuth or token flows — their credentials are managed
694
+ # through the Hermes CLI, not via API keys. The WebUI cannot set these.
695
+ _OAUTH_PROVIDERS = frozenset({
696
+ "copilot",
697
+ "copilot-acp",
698
+ "nous",
699
+ "openai-codex",
700
+ "qwen-oauth",
701
+ "xai-oauth",
702
+ })
703
+
704
+ # SECTION: Helper functions
705
+
706
+
707
+ def _get_hermes_home() -> Path:
708
+ """Return the active Hermes home directory."""
709
+ try:
710
+ from api.profiles import get_active_hermes_home
711
+ return get_active_hermes_home()
712
+ except ImportError:
713
+ return Path.home() / ".hermes"
714
+
715
+
716
+ def _load_env_file(env_path: Path) -> dict[str, str]:
717
+ """Read key=value pairs from a .env file."""
718
+ values: dict[str, str] = {}
719
+ if not env_path.exists():
720
+ return values
721
+ try:
722
+ for raw in env_path.read_text(encoding="utf-8").splitlines():
723
+ line = raw.strip()
724
+ if not line or line.startswith("#") or "=" not in line:
725
+ continue
726
+ key, value = line.split("=", 1)
727
+ values[key.strip()] = value.strip().strip('"').strip("'")
728
+ except Exception:
729
+ return {}
730
+ return values
731
+
732
+
733
+ def _decode_jwt_claims_unverified(token: str) -> dict[str, Any]:
734
+ """Decode JWT claims for token-shape classification only.
735
+
736
+ The signature is intentionally not verified because this helper is not an
737
+ authorization decision: it only prevents a Codex OAuth JWT-shaped value from
738
+ being treated as a raw OpenAI API key in provider-card detection.
739
+ """
740
+ if not isinstance(token, str) or token.count(".") != 2:
741
+ return {}
742
+ payload = token.split(".", 2)[1]
743
+ payload += "=" * ((4 - len(payload) % 4) % 4)
744
+ try:
745
+ claims = json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8"))
746
+ except Exception:
747
+ return {}
748
+ return claims if isinstance(claims, dict) else {}
749
+
750
+
751
+ def _looks_like_codex_oauth_token(value: str) -> bool:
752
+ """Return True when a value is a ChatGPT/Codex OAuth JWT, not an OpenAI API key."""
753
+ token = str(value or "").strip()
754
+ if not token or token.startswith("sk-"):
755
+ return False
756
+ claims = _decode_jwt_claims_unverified(token)
757
+ if not claims:
758
+ return False
759
+ auth_claim = claims.get("https://api.openai.com/auth")
760
+ if isinstance(auth_claim, dict) and auth_claim:
761
+ return True
762
+ return any(key in claims for key in ("chatgpt_account_id", "https://api.openai.com/profile"))
763
+
764
+
765
+ def _provider_value_counts_as_api_key(provider_id: str, value: object) -> bool:
766
+ text = str(value or "").strip()
767
+ if not text:
768
+ return False
769
+ if (provider_id or "").strip().lower() == "openai" and _looks_like_codex_oauth_token(text):
770
+ return False
771
+ return True
772
+
773
+
774
+ def _provider_has_shadowed_codex_oauth_value(provider_id: str) -> bool:
775
+ """True when the bare OpenAI credential slot contains only a Codex OAuth JWT.
776
+
777
+ Users who authenticate Codex can end up with a ChatGPT/Codex JWT in a
778
+ legacy OPENAI_API_KEY-shaped location. That value should not make the bare
779
+ OpenAI API provider appear as configured, and the Providers tab should not
780
+ show an extra OpenAI card solely because of that Codex-only credential.
781
+ """
782
+ if (provider_id or "").strip().lower() != "openai":
783
+ return False
784
+ values: list[object] = []
785
+ env_var = _PROVIDER_ENV_VAR.get(provider_id)
786
+ if env_var:
787
+ env_path = _get_hermes_home() / ".env"
788
+ env_values = _load_env_file(env_path)
789
+ values.append(env_values.get(env_var))
790
+ values.append(os.getenv(env_var))
791
+ for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or ():
792
+ values.append(env_values.get(alias))
793
+ values.append(os.getenv(alias))
794
+
795
+ cfg = get_config()
796
+ model_cfg = cfg.get("model", {})
797
+ if isinstance(model_cfg, dict):
798
+ active_provider = str(model_cfg.get("provider") or "").strip().lower()
799
+ if active_provider == provider_id:
800
+ values.append(model_cfg.get("api_key"))
801
+ providers_cfg = cfg.get("providers", {})
802
+ if isinstance(providers_cfg, dict):
803
+ provider_cfg = providers_cfg.get(provider_id, {})
804
+ if isinstance(provider_cfg, dict):
805
+ values.append(provider_cfg.get("api_key"))
806
+ custom_providers = cfg.get("custom_providers", [])
807
+ if isinstance(custom_providers, list):
808
+ for cp in custom_providers:
809
+ if isinstance(cp, dict) and _custom_provider_name_matches(provider_id, cp.get("name")):
810
+ cp_key = cp.get("api_key")
811
+ if isinstance(cp_key, str) and cp_key.startswith("${") and cp_key.endswith("}"):
812
+ values.append(os.getenv(cp_key[2:-1]))
813
+ else:
814
+ values.append(cp_key)
815
+ return any(_looks_like_codex_oauth_token(str(value or "")) for value in values)
816
+
817
+
818
+ def _write_env_file(env_path: Path, updates: dict[str, str | None]) -> None:
819
+ """Write key=value pairs to the .env file.
820
+
821
+ Values of ``None`` cause the key to be removed.
822
+
823
+ Preserves comments, blank lines, and original key order (#1164).
824
+ New keys are appended at the end of the file with a blank-line separator.
825
+
826
+ Holds ``_ENV_LOCK`` from ``api.streaming`` for the entire load → modify →
827
+ write cycle to prevent TOCTOU races between concurrent POST /api/providers
828
+ calls (each reading the same file baseline and overwriting the other's key).
829
+ Also serialises os.environ mutations with streaming sessions.
830
+ """
831
+ from api.streaming import _ENV_LOCK
832
+ import stat as _stat
833
+
834
+ with _ENV_LOCK:
835
+ # ── Read existing lines (preserving comments and blank lines) ──
836
+ existing_lines: list[str] = []
837
+ if env_path.exists():
838
+ try:
839
+ existing_lines = env_path.read_text(encoding="utf-8").splitlines()
840
+ except Exception:
841
+ existing_lines = []
842
+
843
+ # Map each existing key to its line index so we can update in-place.
844
+ existing_key_indices: dict[str, int] = {}
845
+ for _i, _raw in enumerate(existing_lines):
846
+ _stripped = _raw.strip()
847
+ if _stripped and not _stripped.startswith("#") and "=" in _stripped:
848
+ _existing_key_indices_key = _stripped.split("=", 1)[0].strip()
849
+ existing_key_indices[_existing_key_indices_key] = _i
850
+
851
+ output_lines = list(existing_lines)
852
+ new_keys: list[str] = []
853
+
854
+ for key, value in updates.items():
855
+ if value is None:
856
+ # Mark the line for removal (None sentinel) and clear env.
857
+ os.environ.pop(key, None)
858
+ if key in existing_key_indices:
859
+ output_lines[existing_key_indices[key]] = None # type: ignore[assignment]
860
+ continue
861
+ clean = str(value).strip()
862
+ if not clean:
863
+ continue
864
+ # Reject embedded newlines/carriage returns to prevent .env injection
865
+ if "\n" in clean or "\r" in clean:
866
+ raise ValueError("API key must not contain newline characters.")
867
+ os.environ[key] = clean
868
+
869
+ if key in existing_key_indices:
870
+ output_lines[existing_key_indices[key]] = f"{key}={clean}"
871
+ else:
872
+ new_keys.append(f"{key}={clean}")
873
+
874
+ # Remove deleted lines (None sentinels)
875
+ output_lines = [l for l in output_lines if l is not None]
876
+
877
+ # Append new keys after a blank-line separator
878
+ if new_keys:
879
+ if output_lines and output_lines[-1].strip() != "":
880
+ output_lines.append("")
881
+ output_lines.extend(new_keys)
882
+
883
+ env_path.parent.mkdir(parents=True, exist_ok=True)
884
+ content = "\n".join(output_lines)
885
+ if content:
886
+ content += "\n"
887
+ # Atomic write via tempfile + os.replace so cross-process readers
888
+ # (Telegram bot, CLI) never see a half-truncated file. The shared
889
+ # ``~/.hermes/.env`` is also written by ``hermes_cli.config.save_env_value``
890
+ # using the same atomic pattern; matching it here closes the
891
+ # cross-process leg of #1164 (within-process is covered by _ENV_LOCK).
892
+ _mode = _stat.S_IRUSR | _stat.S_IWUSR # 0o600
893
+ import tempfile as _tempfile
894
+ _tmp_fd, _tmp_path = _tempfile.mkstemp(
895
+ dir=str(env_path.parent), prefix=".env_", suffix=".tmp"
896
+ )
897
+ try:
898
+ with os.fdopen(_tmp_fd, "w", encoding="utf-8") as _f:
899
+ _f.write(content)
900
+ _f.flush()
901
+ os.fsync(_f.fileno())
902
+ os.chmod(_tmp_path, _mode) # tighten before rename so readers see 0600
903
+ os.replace(_tmp_path, env_path)
904
+ except BaseException:
905
+ try:
906
+ os.unlink(_tmp_path)
907
+ except OSError:
908
+ pass
909
+ raise
910
+ try:
911
+ env_path.chmod(_mode)
912
+ except OSError:
913
+ pass
914
+
915
+
916
+ def _provider_has_key(provider_id: str) -> bool:
917
+ """Check whether a provider has a configured API key.
918
+
919
+ Checks (in order):
920
+ 1. ``~/.hermes/.env`` for the known env var
921
+ 2. ``os.environ`` for the known env var
922
+ 3. ``config.yaml → model.api_key`` (only if provider is the active one)
923
+ 4. ``config.yaml → providers.<id>.api_key``
924
+ 5. ``config.yaml → custom_providers[].api_key`` (for custom providers)
925
+ """
926
+ env_var = _PROVIDER_ENV_VAR.get(provider_id)
927
+ if env_var:
928
+ env_path = _get_hermes_home() / ".env"
929
+ env_values = _load_env_file(env_path)
930
+ env_file_value = env_values.get(env_var)
931
+ if _provider_value_counts_as_api_key(provider_id, env_file_value):
932
+ return True
933
+ env_value = os.getenv(env_var)
934
+ if _provider_value_counts_as_api_key(provider_id, env_value):
935
+ return True
936
+ # Fall back to legacy env-var aliases (e.g. lmstudio's pre-#1500
937
+ # LMSTUDIO_API_KEY name) so existing users don't lose detection
938
+ # after an env-var rename. See _PROVIDER_ENV_VAR_ALIASES.
939
+ for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or ():
940
+ if _provider_value_counts_as_api_key(provider_id, env_values.get(alias)):
941
+ return True
942
+ if _provider_value_counts_as_api_key(provider_id, os.getenv(alias)):
943
+ return True
944
+
945
+ cfg = get_config()
946
+ # Check model.api_key — only match if this provider is the active one.
947
+ # Previously this checked globally, causing all providers to show
948
+ # "configured" when the active provider had a top-level api_key.
949
+ model_cfg = cfg.get("model", {})
950
+ if isinstance(model_cfg, dict) and str(model_cfg.get("api_key") or "").strip():
951
+ active_provider = model_cfg.get("provider")
952
+ if active_provider and str(active_provider).strip().lower() == provider_id.lower():
953
+ if _provider_value_counts_as_api_key(provider_id, model_cfg.get("api_key")):
954
+ return True
955
+ # Check providers.<id>.api_key
956
+ providers_cfg = cfg.get("providers", {})
957
+ if isinstance(providers_cfg, dict):
958
+ provider_cfg = providers_cfg.get(provider_id, {})
959
+ if isinstance(provider_cfg, dict) and str(provider_cfg.get("api_key") or "").strip():
960
+ if _provider_value_counts_as_api_key(provider_id, provider_cfg.get("api_key")):
961
+ return True
962
+ # Check custom_providers
963
+ custom_providers = cfg.get("custom_providers", [])
964
+ if isinstance(custom_providers, list):
965
+ for cp in custom_providers:
966
+ if isinstance(cp, dict):
967
+ if _custom_provider_name_matches(provider_id, cp.get("name")):
968
+ if _provider_value_counts_as_api_key(provider_id, cp.get("api_key")):
969
+ return True
970
+ return False
971
+
972
+
973
+ def _get_provider_api_key(provider_id: str) -> str | None:
974
+ """Return a configured provider API key without exposing it to callers."""
975
+ provider_id = (provider_id or "").strip().lower()
976
+ env_var = _PROVIDER_ENV_VAR.get(provider_id)
977
+ if env_var:
978
+ env_path = _get_hermes_home() / ".env"
979
+ env_values = _load_env_file(env_path)
980
+ env_file_value = env_values.get(env_var)
981
+ if _provider_value_counts_as_api_key(provider_id, env_file_value):
982
+ return str(env_file_value).strip() or None
983
+ env_value = os.getenv(env_var)
984
+ if _provider_value_counts_as_api_key(provider_id, env_value):
985
+ return str(env_value).strip() or None
986
+ for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or ():
987
+ alias_file_value = env_values.get(alias)
988
+ if _provider_value_counts_as_api_key(provider_id, alias_file_value):
989
+ return str(alias_file_value).strip() or None
990
+ alias_value = os.getenv(alias)
991
+ if _provider_value_counts_as_api_key(provider_id, alias_value):
992
+ return str(alias_value).strip() or None
993
+
994
+ cfg = get_config()
995
+ model_cfg = cfg.get("model", {})
996
+ if isinstance(model_cfg, dict):
997
+ active_provider = str(model_cfg.get("provider") or "").strip().lower()
998
+ model_key = str(model_cfg.get("api_key") or "").strip()
999
+ if model_key and active_provider == provider_id and _provider_value_counts_as_api_key(provider_id, model_key):
1000
+ return model_key
1001
+
1002
+ providers_cfg = cfg.get("providers", {})
1003
+ if isinstance(providers_cfg, dict):
1004
+ provider_cfg = providers_cfg.get(provider_id, {})
1005
+ if isinstance(provider_cfg, dict):
1006
+ provider_key = str(provider_cfg.get("api_key") or "").strip()
1007
+ if _provider_value_counts_as_api_key(provider_id, provider_key):
1008
+ return provider_key
1009
+
1010
+ custom_providers = cfg.get("custom_providers", [])
1011
+ if isinstance(custom_providers, list):
1012
+ for cp in custom_providers:
1013
+ if not isinstance(cp, dict):
1014
+ continue
1015
+ if _custom_provider_name_matches(provider_id, cp.get("name")):
1016
+ cp_key = str(cp.get("api_key") or "").strip()
1017
+ if cp_key.startswith("${") and cp_key.endswith("}"):
1018
+ return os.getenv(cp_key[2:-1], "").strip() or None
1019
+ if _provider_value_counts_as_api_key(provider_id, cp_key):
1020
+ return cp_key
1021
+ return None
1022
+
1023
+
1024
+ def _active_provider_id() -> str | None:
1025
+ cfg = get_config()
1026
+ model_cfg = cfg.get("model", {})
1027
+ if not isinstance(model_cfg, dict):
1028
+ return None
1029
+ provider = str(model_cfg.get("provider") or "").strip().lower()
1030
+ return provider or None
1031
+
1032
+
1033
+ def _quota_number(value: Any) -> int | float | None:
1034
+ if isinstance(value, bool) or value is None:
1035
+ return None
1036
+ if isinstance(value, (int, float)):
1037
+ return value
1038
+ try:
1039
+ text = str(value).strip()
1040
+ if not text:
1041
+ return None
1042
+ number = float(text)
1043
+ return int(number) if number.is_integer() else number
1044
+ except (TypeError, ValueError):
1045
+ return None
1046
+
1047
+
1048
+ def _sanitize_openrouter_quota(payload: Any) -> dict[str, int | float | None]:
1049
+ if isinstance(payload, dict) and isinstance(payload.get("data"), dict):
1050
+ payload = payload["data"]
1051
+ if not isinstance(payload, dict):
1052
+ payload = {}
1053
+ return {
1054
+ "limit_remaining": _quota_number(payload.get("limit_remaining")),
1055
+ "usage": _quota_number(payload.get("usage")),
1056
+ "limit": _quota_number(payload.get("limit")),
1057
+ }
1058
+
1059
+
1060
+ def _isoformat_utc(value: Any) -> str | None:
1061
+ if value in (None, ""):
1062
+ return None
1063
+ if isinstance(value, datetime):
1064
+ dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
1065
+ return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
1066
+ text = str(value).strip()
1067
+ return text or None
1068
+
1069
+
1070
+ def _serialize_account_usage_snapshot(snapshot: Any) -> dict[str, Any] | None:
1071
+ if snapshot is None:
1072
+ return None
1073
+ windows: list[dict[str, Any]] = []
1074
+ for window in getattr(snapshot, "windows", ()) or ():
1075
+ label = str(getattr(window, "label", "") or "").strip()
1076
+ if not label:
1077
+ continue
1078
+ used_percent = _quota_number(getattr(window, "used_percent", None))
1079
+ remaining_percent = None
1080
+ if used_percent is not None:
1081
+ remaining_percent = max(0.0, min(100.0, 100.0 - float(used_percent)))
1082
+ windows.append({
1083
+ "label": label,
1084
+ "used_percent": used_percent,
1085
+ "remaining_percent": remaining_percent,
1086
+ "reset_at": _isoformat_utc(getattr(window, "reset_at", None)),
1087
+ "detail": str(getattr(window, "detail", "") or "").strip() or None,
1088
+ })
1089
+
1090
+ details = [
1091
+ str(detail).strip()
1092
+ for detail in (getattr(snapshot, "details", ()) or ())
1093
+ if str(detail).strip()
1094
+ ]
1095
+ plan = str(getattr(snapshot, "plan", "") or "").strip() or None
1096
+ unavailable_reason = str(getattr(snapshot, "unavailable_reason", "") or "").strip() or None
1097
+ result = {
1098
+ "provider": str(getattr(snapshot, "provider", "") or "").strip() or None,
1099
+ "source": str(getattr(snapshot, "source", "") or "").strip() or None,
1100
+ "title": str(getattr(snapshot, "title", "") or "").strip() or "Account limits",
1101
+ "plan": plan,
1102
+ "windows": windows,
1103
+ "details": details,
1104
+ "available": bool(getattr(snapshot, "available", bool(windows or details))) and not unavailable_reason,
1105
+ "unavailable_reason": unavailable_reason,
1106
+ "fetched_at": _isoformat_utc(getattr(snapshot, "fetched_at", None)),
1107
+ }
1108
+ pool = getattr(snapshot, "pool", None)
1109
+ if isinstance(pool, dict):
1110
+ result["pool"] = pool
1111
+ return result
1112
+
1113
+
1114
+ def _agent_fetch_account_usage(provider: str, *, base_url: str | None = None, api_key: str | None = None) -> Any:
1115
+ from agent.account_usage import fetch_account_usage
1116
+
1117
+ return fetch_account_usage(provider, base_url=base_url, api_key=api_key)
1118
+
1119
+
1120
+ def _account_usage_subprocess_env(home: Path, provider: str, api_key: str | None) -> dict[str, str]:
1121
+ env = dict(os.environ)
1122
+ env["HERMES_HOME"] = str(Path(home))
1123
+
1124
+ # Profile .env values should affect only the child quota probe, not the
1125
+ # WebUI process-global environment. This is especially important for
1126
+ # Anthropic account usage, where the agent resolver reads OAuth/API tokens
1127
+ # from environment variables.
1128
+ for key, value in _load_env_file(Path(home) / ".env").items():
1129
+ if value:
1130
+ env[key] = value
1131
+
1132
+ env_var = _PROVIDER_ENV_VAR.get((provider or "").strip().lower())
1133
+ if env_var and api_key:
1134
+ env[env_var] = api_key
1135
+
1136
+ try:
1137
+ from api.config import _AGENT_DIR
1138
+ except Exception:
1139
+ _AGENT_DIR = None
1140
+ pythonpath_parts: list[str] = []
1141
+ if _AGENT_DIR:
1142
+ pythonpath_parts.append(str(_AGENT_DIR))
1143
+ existing_pythonpath = env.get("PYTHONPATH", "")
1144
+ if existing_pythonpath:
1145
+ pythonpath_parts.append(existing_pythonpath)
1146
+ if pythonpath_parts:
1147
+ env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)
1148
+ return env
1149
+
1150
+
1151
+ def _account_usage_payload_to_snapshot(payload: Any) -> Any:
1152
+ if not isinstance(payload, dict):
1153
+ return None
1154
+ windows = tuple(
1155
+ SimpleNamespace(
1156
+ label=window.get("label"),
1157
+ used_percent=window.get("used_percent"),
1158
+ reset_at=window.get("reset_at"),
1159
+ detail=window.get("detail"),
1160
+ )
1161
+ for window in (payload.get("windows") or ())
1162
+ if isinstance(window, dict)
1163
+ )
1164
+ return SimpleNamespace(
1165
+ provider=payload.get("provider"),
1166
+ source=payload.get("source"),
1167
+ title=payload.get("title"),
1168
+ plan=payload.get("plan"),
1169
+ windows=windows,
1170
+ details=tuple(payload.get("details") or ()),
1171
+ available=bool(payload.get("available")),
1172
+ unavailable_reason=payload.get("unavailable_reason"),
1173
+ fetched_at=payload.get("fetched_at"),
1174
+ pool=payload.get("pool") if isinstance(payload.get("pool"), dict) else None,
1175
+ )
1176
+
1177
+
1178
+ def _account_usage_cache_key(provider: str, home: Path, api_key: str | None) -> tuple[str, str, str]:
1179
+ key_fingerprint = ""
1180
+ if api_key:
1181
+ key_fingerprint = hashlib.sha256(api_key.encode("utf-8", "ignore")).hexdigest()
1182
+ return ((provider or "").strip().lower(), str(Path(home)), key_fingerprint)
1183
+
1184
+
1185
+ def _get_cached_account_usage(cache_key: tuple[str, str, str]) -> tuple[bool, Any]:
1186
+ now = time.monotonic()
1187
+ with _account_usage_status_cache_lock:
1188
+ cached = _account_usage_status_cache.get(cache_key)
1189
+ if cached is None:
1190
+ return False, None
1191
+ fetched_at, snapshot = cached
1192
+ if now - fetched_at <= _ACCOUNT_USAGE_CACHE_TTL_SECONDS:
1193
+ return True, snapshot
1194
+ _account_usage_status_cache.pop(cache_key, None)
1195
+ return False, None
1196
+
1197
+
1198
+ def invalidate_account_usage_status_cache(provider_id: str | None = None) -> None:
1199
+ normalized = str(provider_id or "").strip().lower()
1200
+ with _account_usage_status_cache_lock:
1201
+ if not normalized:
1202
+ _account_usage_status_cache.clear()
1203
+ return
1204
+ for key in list(_account_usage_status_cache):
1205
+ if key[0] == normalized:
1206
+ _account_usage_status_cache.pop(key, None)
1207
+
1208
+
1209
+ def _set_cached_account_usage(
1210
+ cache_key: tuple[str, str, str],
1211
+ snapshot: Any,
1212
+ ) -> None:
1213
+ now = time.monotonic()
1214
+ with _account_usage_status_cache_lock:
1215
+ if snapshot is None:
1216
+ cached = _account_usage_status_cache.get(cache_key)
1217
+ if cached is not None and cached[1] is not None:
1218
+ return
1219
+ _account_usage_status_cache.pop(cache_key, None)
1220
+ return
1221
+ _account_usage_status_cache[cache_key] = (now, snapshot)
1222
+ expired = [
1223
+ key for key, (fetched_at, _snapshot) in _account_usage_status_cache.items()
1224
+ if now - fetched_at > _ACCOUNT_USAGE_CACHE_TTL_SECONDS
1225
+ ]
1226
+ for key in expired:
1227
+ _account_usage_status_cache.pop(key, None)
1228
+ while len(_account_usage_status_cache) > _ACCOUNT_USAGE_CACHE_MAX_ENTRIES:
1229
+ oldest_key = min(
1230
+ _account_usage_status_cache,
1231
+ key=lambda key: _account_usage_status_cache[key][0],
1232
+ )
1233
+ _account_usage_status_cache.pop(oldest_key, None)
1234
+
1235
+
1236
+ def _agent_fetch_account_usage_for_home(provider: str, home: Path, *, api_key: str | None = None) -> Any:
1237
+ try:
1238
+ from api.config import PYTHON_EXE
1239
+ except Exception:
1240
+ PYTHON_EXE = sys.executable or "python3"
1241
+
1242
+ try:
1243
+ # On POSIX (Linux/macOS), wire parent-death signal so the child dies
1244
+ # cleanly if the WebUI parent terminates. preexec_fn is not safe on
1245
+ # Windows, where OS-level process-tree cleanup handles child orphans.
1246
+ kwargs: dict[str, Any] = {
1247
+ "stdin": subprocess.DEVNULL,
1248
+ "stdout": subprocess.PIPE,
1249
+ "stderr": subprocess.PIPE,
1250
+ "text": True,
1251
+ "timeout": _ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS,
1252
+ "check": False,
1253
+ }
1254
+ if hasattr(os, "fork"): # POSIX
1255
+ kwargs["preexec_fn"] = _account_usage_preexec_fn
1256
+
1257
+ proc = subprocess.run(
1258
+ [
1259
+ PYTHON_EXE, "-c",
1260
+ _ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP + _ACCOUNT_USAGE_SUBPROCESS_CODE,
1261
+ provider,
1262
+ api_key or "",
1263
+ ],
1264
+ env=_account_usage_subprocess_env(home, provider, api_key),
1265
+ **kwargs,
1266
+ )
1267
+ except subprocess.TimeoutExpired:
1268
+ logger.debug("Account usage probe for %s timed out", provider)
1269
+ return None
1270
+ except Exception:
1271
+ logger.debug("Account usage probe for %s failed to launch", provider, exc_info=True)
1272
+ return None
1273
+
1274
+ if proc.returncode != 0:
1275
+ logger.debug("Account usage probe for %s exited with status %s", provider, proc.returncode)
1276
+ return None
1277
+ try:
1278
+ payload = json.loads((proc.stdout or "").strip() or "null")
1279
+ except json.JSONDecodeError:
1280
+ logger.debug("Account usage probe for %s returned invalid JSON", provider)
1281
+ return None
1282
+ return _account_usage_payload_to_snapshot(payload)
1283
+
1284
+
1285
+ def _fetch_account_usage_with_profile_context(provider: str, *, refresh: bool = False) -> Any:
1286
+ """Fetch account usage for a provider within the active profile context.
1287
+
1288
+ Concurrency is capped by the module-level BoundedSemaphore so that rapid
1289
+ UI polls (e.g. Settings page refresh) cannot exhaust file-descriptors or
1290
+ memory by spawning more than _MAX_CONCURRENT_ACCOUNT_USAGE_PROBES probe
1291
+ subprocesses simultaneously. Each probe runs up to 35 s.
1292
+
1293
+ A warm worker-pool (reuse of persistent subprocess handles) is a natural
1294
+ follow-up if this first slice proves insufficient in production.
1295
+ """
1296
+ home = _get_hermes_home()
1297
+ api_key = _get_provider_api_key(provider)
1298
+ cache_key = _account_usage_cache_key(provider, home, api_key)
1299
+ if not refresh:
1300
+ cache_hit, cached = _get_cached_account_usage(cache_key)
1301
+ if cache_hit:
1302
+ return cached
1303
+ sem = _get_account_usage_probe_semaphore()
1304
+ try:
1305
+ with sem:
1306
+ snapshot = _agent_fetch_account_usage_for_home(
1307
+ provider,
1308
+ home,
1309
+ api_key=api_key,
1310
+ )
1311
+ _set_cached_account_usage(cache_key, snapshot)
1312
+ return snapshot
1313
+ except Exception:
1314
+ logger.debug("Failed to fetch account usage for %s", provider, exc_info=True)
1315
+ _set_cached_account_usage(cache_key, None)
1316
+ return None
1317
+
1318
+
1319
+ def _provider_account_usage_status(provider: str, display_name: str, *, refresh: bool = False) -> dict[str, Any]:
1320
+ snapshot = _fetch_account_usage_with_profile_context(provider, refresh=refresh)
1321
+ account_limits = _serialize_account_usage_snapshot(snapshot)
1322
+ if account_limits and account_limits.get("available"):
1323
+ return {
1324
+ "ok": True,
1325
+ "provider": provider,
1326
+ "display_name": display_name,
1327
+ "supported": True,
1328
+ "status": "available",
1329
+ "label": account_limits.get("title") or "Account limits",
1330
+ "quota": None,
1331
+ "account_limits": account_limits,
1332
+ "message": f"{display_name} account limits loaded.",
1333
+ }
1334
+
1335
+ reason = ""
1336
+ if account_limits:
1337
+ reason = str(account_limits.get("unavailable_reason") or "").strip()
1338
+ message = (
1339
+ f"{display_name} account limits are unavailable. {reason}"
1340
+ if reason
1341
+ else f"{display_name} account limits are unavailable. Confirm provider authentication and try again."
1342
+ )
1343
+ return {
1344
+ "ok": False,
1345
+ "provider": provider,
1346
+ "display_name": display_name,
1347
+ "supported": True,
1348
+ "status": "unavailable",
1349
+ "quota": None,
1350
+ "account_limits": account_limits,
1351
+ "message": message,
1352
+ }
1353
+
1354
+
1355
+ def get_provider_quota(provider_id: str | None = None, *, refresh: bool = False) -> dict[str, Any]:
1356
+ """Return sanitized quota/rate-limit status for the active provider.
1357
+
1358
+ OpenRouter keeps its documented key endpoint. OAuth-backed account usage
1359
+ providers reuse Hermes Agent's /usage account-limits abstraction so WebUI
1360
+ stays aligned with CLI/Gateway provider semantics.
1361
+ """
1362
+ provider = (provider_id or _active_provider_id() or "").strip().lower()
1363
+ if not provider:
1364
+ return {
1365
+ "ok": False,
1366
+ "provider": None,
1367
+ "display_name": None,
1368
+ "supported": False,
1369
+ "status": "unavailable",
1370
+ "quota": None,
1371
+ "message": "No active provider is configured.",
1372
+ }
1373
+
1374
+ display_name = _PROVIDER_DISPLAY.get(provider, provider.replace("-", " ").title())
1375
+ if provider in _ACCOUNT_USAGE_PROVIDERS:
1376
+ return _provider_account_usage_status(provider, display_name, refresh=refresh)
1377
+
1378
+ if provider != "openrouter":
1379
+ detail = "OpenAI/Anthropic rate-limit headers are a follow-up once WebUI captures provider response metadata."
1380
+ return {
1381
+ "ok": False,
1382
+ "provider": provider,
1383
+ "display_name": display_name,
1384
+ "supported": False,
1385
+ "status": "unsupported",
1386
+ "quota": None,
1387
+ "message": f"Quota status is not available for {display_name}. {detail}",
1388
+ }
1389
+
1390
+ api_key = _get_provider_api_key("openrouter")
1391
+ if not api_key:
1392
+ return {
1393
+ "ok": False,
1394
+ "provider": "openrouter",
1395
+ "display_name": display_name,
1396
+ "supported": True,
1397
+ "status": "no_key",
1398
+ "quota": None,
1399
+ "message": "OpenRouter quota status needs an OPENROUTER_API_KEY configured on the server.",
1400
+ }
1401
+
1402
+ req = urllib.request.Request(
1403
+ _OPENROUTER_KEY_URL,
1404
+ headers={
1405
+ "Authorization": f"Bearer {api_key}",
1406
+ "Accept": "application/json",
1407
+ },
1408
+ )
1409
+ try:
1410
+ with urllib.request.urlopen(req, timeout=_PROVIDER_QUOTA_TIMEOUT_SECONDS) as resp:
1411
+ raw = resp.read()
1412
+ payload = json.loads(raw.decode("utf-8")) if isinstance(raw, (bytes, bytearray)) else json.loads(raw)
1413
+ quota = _sanitize_openrouter_quota(payload)
1414
+ return {
1415
+ "ok": True,
1416
+ "provider": "openrouter",
1417
+ "display_name": display_name,
1418
+ "supported": True,
1419
+ "status": "available",
1420
+ "label": "OpenRouter credits",
1421
+ "quota": quota,
1422
+ "message": "OpenRouter quota status loaded.",
1423
+ }
1424
+ except urllib.error.HTTPError as exc:
1425
+ status = "invalid_key" if exc.code in (401, 403) else "unavailable"
1426
+ message = (
1427
+ "OpenRouter rejected the configured API key."
1428
+ if status == "invalid_key"
1429
+ else "OpenRouter quota status is temporarily unavailable."
1430
+ )
1431
+ return {
1432
+ "ok": False,
1433
+ "provider": "openrouter",
1434
+ "display_name": display_name,
1435
+ "supported": True,
1436
+ "status": status,
1437
+ "quota": None,
1438
+ "message": message,
1439
+ }
1440
+ except (TimeoutError, urllib.error.URLError, json.JSONDecodeError, OSError, ValueError):
1441
+ return {
1442
+ "ok": False,
1443
+ "provider": "openrouter",
1444
+ "display_name": display_name,
1445
+ "supported": True,
1446
+ "status": "unavailable",
1447
+ "quota": None,
1448
+ "message": "OpenRouter quota status is temporarily unavailable.",
1449
+ }
1450
+
1451
+
1452
+ def _provider_is_oauth(provider_id: str) -> bool:
1453
+ """Check whether a provider uses OAuth/token flows (managed by CLI)."""
1454
+ return provider_id in _OAUTH_PROVIDERS
1455
+
1456
+
1457
+ # ── OpenRouter cost-history snapshot helpers (#692) ──────────────────────────
1458
+
1459
+ _COST_SNAPSHOTS_DIR_NAME = "cost-snapshots"
1460
+ _COST_SNAPSHOT_MAX_DAYS = 365 # hard cap to prevent unbounded growth
1461
+ _COST_SNAPSHOT_LOCK = threading.Lock()
1462
+
1463
+
1464
+ def _cost_snapshots_dir() -> Path:
1465
+ """Return the directory for cost-snapshot JSON files.
1466
+
1467
+ Uses the Hermes home directory (profile-aware) so snapshots are
1468
+ isolated per profile, matching the existing STATE_DIR convention.
1469
+ """
1470
+ return _get_hermes_home() / _COST_SNAPSHOTS_DIR_NAME
1471
+
1472
+
1473
+ @contextmanager
1474
+ def _cost_snapshot_file_lock(provider: str):
1475
+ """Serialize cost snapshot read-modify-write across worker processes."""
1476
+ if fcntl is None:
1477
+ with nullcontext():
1478
+ yield
1479
+ return
1480
+
1481
+ snap_dir = _cost_snapshots_dir()
1482
+ snap_dir.mkdir(parents=True, exist_ok=True)
1483
+ lock_path = snap_dir / f"{provider}.lock"
1484
+ with lock_path.open("a", encoding="utf-8") as lock_file:
1485
+ fcntl.flock(lock_file, fcntl.LOCK_EX)
1486
+ yield
1487
+
1488
+
1489
+ def _fetch_openrouter_key_usage(api_key: str) -> dict[str, Any] | None:
1490
+ """Fetch current usage/limit from the OpenRouter ``/auth/key`` endpoint.
1491
+
1492
+ Returns a dict with ``usage``, ``limit``, ``label`` on success, or
1493
+ ``None`` on any failure. Never raises; callers handle the None case.
1494
+ """
1495
+ req = urllib.request.Request(
1496
+ _OPENROUTER_KEY_URL,
1497
+ headers={
1498
+ "Authorization": f"Bearer {api_key}",
1499
+ "Accept": "application/json",
1500
+ },
1501
+ )
1502
+ try:
1503
+ with urllib.request.urlopen(req, timeout=_PROVIDER_QUOTA_TIMEOUT_SECONDS) as resp:
1504
+ raw = resp.read()
1505
+ payload = json.loads(raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw)
1506
+ sanitized = _sanitize_openrouter_quota(payload)
1507
+ label = None
1508
+ if isinstance(payload, dict):
1509
+ data = payload.get("data", payload)
1510
+ if isinstance(data, dict):
1511
+ label = str(data.get("label") or "").strip() or None
1512
+ return {
1513
+ "usage": sanitized.get("usage"),
1514
+ "limit": sanitized.get("limit"),
1515
+ "label": label,
1516
+ }
1517
+ except Exception:
1518
+ logger.debug("OpenRouter key usage fetch failed for cost-history", exc_info=True)
1519
+ return None
1520
+
1521
+
1522
+ def _read_cost_snapshots(provider: str) -> list[dict[str, Any]]:
1523
+ """Read persisted daily snapshots for *provider* from disk.
1524
+
1525
+ Returns a list of ``{date, used, limit}`` dicts sorted by date
1526
+ ascending. Returns an empty list if the file does not exist or is
1527
+ corrupt.
1528
+ """
1529
+ path = _cost_snapshots_dir() / f"{provider}.json"
1530
+ if not path.exists():
1531
+ return []
1532
+ try:
1533
+ raw = path.read_text(encoding="utf-8")
1534
+ data = json.loads(raw)
1535
+ except (OSError, json.JSONDecodeError, ValueError):
1536
+ return []
1537
+ if not isinstance(data, dict):
1538
+ return []
1539
+ snapshots = data.get("snapshots")
1540
+ if not isinstance(snapshots, list):
1541
+ return []
1542
+ # Validate and sort
1543
+ valid = []
1544
+ for entry in snapshots:
1545
+ if not isinstance(entry, dict):
1546
+ continue
1547
+ date = str(entry.get("date") or "").strip()
1548
+ if not date:
1549
+ continue
1550
+ valid.append({
1551
+ "date": date,
1552
+ "used": _quota_number(entry.get("used")),
1553
+ "limit": _quota_number(entry.get("limit")),
1554
+ })
1555
+ valid.sort(key=lambda e: e["date"])
1556
+ return valid
1557
+
1558
+
1559
+ def _write_cost_snapshots(provider: str, snapshots: list[dict[str, Any]]) -> None:
1560
+ """Persist daily snapshots for *provider* to disk atomically."""
1561
+ snap_dir = _cost_snapshots_dir()
1562
+ snap_dir.mkdir(parents=True, exist_ok=True)
1563
+ path = snap_dir / f"{provider}.json"
1564
+ payload = {"provider": provider, "snapshots": snapshots}
1565
+ body = json.dumps(payload, ensure_ascii=False, indent=2)
1566
+ import tempfile as _tempfile
1567
+ _tmp_fd, _tmp_path = _tempfile.mkstemp(
1568
+ dir=str(snap_dir), prefix=f".{provider}_", suffix=".tmp"
1569
+ )
1570
+ try:
1571
+ with os.fdopen(_tmp_fd, "w", encoding="utf-8") as _f:
1572
+ _f.write(body)
1573
+ _f.flush()
1574
+ os.fsync(_f.fileno())
1575
+ os.replace(_tmp_path, path)
1576
+ except BaseException:
1577
+ try:
1578
+ os.unlink(_tmp_path)
1579
+ except OSError:
1580
+ pass
1581
+ raise
1582
+
1583
+
1584
+ def _append_cost_snapshot(provider: str, usage: int | float | None, limit: int | float | None) -> list[dict[str, Any]]:
1585
+ """Append today's snapshot and return the updated list.
1586
+
1587
+ If a snapshot for today already exists it is updated in-place so
1588
+ repeated calls within the same day are idempotent.
1589
+ """
1590
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
1591
+ # Serialize the read-modify-write cycle. The atomic os.replace in
1592
+ # _write_cost_snapshots protects the file write itself, but without these
1593
+ # locks two concurrent requests can both read the same old snapshot list and
1594
+ # race to replace it with stale data. The threading lock covers current
1595
+ # single-process deployments; the file lock covers future multi-worker
1596
+ # deployments that share one Hermes home/state directory.
1597
+ with _COST_SNAPSHOT_LOCK:
1598
+ with _cost_snapshot_file_lock(provider):
1599
+ snapshots = _read_cost_snapshots(provider)
1600
+ # Update or append today's entry
1601
+ updated = False
1602
+ for entry in snapshots:
1603
+ if entry["date"] == today:
1604
+ entry["used"] = usage
1605
+ entry["limit"] = limit
1606
+ updated = True
1607
+ break
1608
+ if not updated:
1609
+ snapshots.append({"date": today, "used": usage, "limit": limit})
1610
+ snapshots.sort(key=lambda e: e["date"])
1611
+ # Cap to _COST_SNAPSHOT_MAX_DAYS entries (keep most recent)
1612
+ if len(snapshots) > _COST_SNAPSHOT_MAX_DAYS:
1613
+ snapshots = snapshots[-_COST_SNAPSHOT_MAX_DAYS:]
1614
+ _write_cost_snapshots(provider, snapshots)
1615
+ return snapshots
1616
+
1617
+
1618
+ def _compute_deltas(snapshots: list[dict[str, Any]], window_days: int) -> list[dict[str, Any]]:
1619
+ """Compute daily deltas from cumulative usage snapshots.
1620
+
1621
+ Each snapshot carries cumulative ``used``; the delta for a day is
1622
+ the difference between that day's cumulative value and the previous
1623
+ day's. The oldest day in the window has ``delta=None`` (no
1624
+ previous baseline). If the cumulative value drops, treat that day
1625
+ as the start of a fresh series (for example after an API-key rotation)
1626
+ and use the current value as that day's delta instead of emitting a
1627
+ negative spend bar.
1628
+ """
1629
+ # Take only the last *window_days* entries
1630
+ window = snapshots[-window_days:] if len(snapshots) > window_days else list(snapshots)
1631
+ result: list[dict[str, Any]] = []
1632
+ for i, entry in enumerate(window):
1633
+ delta = None
1634
+ if i > 0 and entry.get("used") is not None and window[i - 1].get("used") is not None:
1635
+ delta = float(entry["used"]) - float(window[i - 1]["used"])
1636
+ if delta < 0:
1637
+ delta = float(entry["used"])
1638
+ # Rounding: avoid -0.0 and tiny floating-point noise
1639
+ if abs(delta) < 1e-9:
1640
+ delta = 0.0
1641
+ else:
1642
+ delta = round(delta, 6)
1643
+ result.append({
1644
+ "date": entry["date"],
1645
+ "used": entry.get("used"),
1646
+ "delta": delta,
1647
+ })
1648
+ return result
1649
+
1650
+
1651
+ def get_provider_cost_history(provider_id: str | None = None, days: int = 7) -> dict[str, Any]:
1652
+ """Return daily cost-history snapshots with deltas for a provider.
1653
+
1654
+ Currently only ``openrouter`` is supported. On each call the
1655
+ endpoint fetches the current cumulative usage from the OpenRouter
1656
+ ``/auth/key`` endpoint, appends/updates today's snapshot, and
1657
+ returns the last *days* snapshots with per-day deltas.
1658
+
1659
+ Returns a dict matching the existing API style (``ok``, ``provider``,
1660
+ ``status``, ``message``, …).
1661
+ """
1662
+ provider = (provider_id or "").strip().lower()
1663
+ if not provider:
1664
+ return {
1665
+ "ok": False,
1666
+ "provider": None,
1667
+ "status": "missing_provider",
1668
+ "message": "Provider parameter is required. Use ?provider=openrouter",
1669
+ }
1670
+
1671
+ if provider != "openrouter":
1672
+ display_name = _PROVIDER_DISPLAY.get(provider, provider.replace("-", " ").title())
1673
+ return {
1674
+ "ok": False,
1675
+ "provider": provider,
1676
+ "display_name": display_name,
1677
+ "supported": False,
1678
+ "status": "unsupported",
1679
+ "message": f"Cost history is not available for {display_name}. Only openrouter is supported in this release.",
1680
+ }
1681
+
1682
+ display_name = _PROVIDER_DISPLAY.get("openrouter", "OpenRouter")
1683
+ api_key = _get_provider_api_key("openrouter")
1684
+ if not api_key:
1685
+ return {
1686
+ "ok": False,
1687
+ "provider": "openrouter",
1688
+ "display_name": display_name,
1689
+ "supported": True,
1690
+ "status": "no_key",
1691
+ "message": "OpenRouter cost history needs an OPENROUTER_API_KEY configured on the server.",
1692
+ }
1693
+
1694
+ # Fetch current cumulative usage from OpenRouter
1695
+ key_info = _fetch_openrouter_key_usage(api_key)
1696
+ if key_info is None:
1697
+ # Upstream failure — still return any previously persisted snapshots
1698
+ # so the chart degrades gracefully instead of going blank.
1699
+ snapshots = _read_cost_snapshots("openrouter")
1700
+ deltas = _compute_deltas(snapshots, days)
1701
+ return {
1702
+ "ok": False,
1703
+ "provider": "openrouter",
1704
+ "display_name": display_name,
1705
+ "supported": True,
1706
+ "status": "unavailable",
1707
+ "window_days": days,
1708
+ "snapshots": deltas,
1709
+ "limit": None,
1710
+ "label": None,
1711
+ "message": "OpenRouter cost history is temporarily unavailable. Showing last known data.",
1712
+ }
1713
+
1714
+ # Persist today's snapshot
1715
+ try:
1716
+ snapshots = _append_cost_snapshot("openrouter", key_info["usage"], key_info["limit"])
1717
+ except Exception:
1718
+ logger.debug("Failed to persist cost snapshot for openrouter", exc_info=True)
1719
+ snapshots = _read_cost_snapshots("openrouter")
1720
+
1721
+ deltas = _compute_deltas(snapshots, days)
1722
+ return {
1723
+ "ok": True,
1724
+ "provider": "openrouter",
1725
+ "display_name": display_name,
1726
+ "supported": True,
1727
+ "status": "available",
1728
+ "window_days": days,
1729
+ "snapshots": deltas,
1730
+ "limit": key_info.get("limit"),
1731
+ "label": key_info.get("label") or "OpenRouter credits",
1732
+ "message": "OpenRouter cost history loaded.",
1733
+ }
1734
+
1735
+
1736
+ # SECTION: Public API
1737
+
1738
+
1739
+ def get_providers() -> dict[str, Any]:
1740
+ """Return a list of all known providers with their configuration status.
1741
+
1742
+ Each entry contains:
1743
+ - ``id``: canonical provider slug
1744
+ - ``display_name``: human-readable name
1745
+ - ``has_key``: whether an API key is configured
1746
+ - ``configurable``: whether the key can be set from the WebUI
1747
+ - ``key_source``: where the key was found (``env_file``, ``env_var``,
1748
+ ``config_yaml``, ``oauth``, ``none``)
1749
+ - ``models``: list of known model IDs for this provider
1750
+ """
1751
+ providers = []
1752
+
1753
+ # Collect all known provider IDs from multiple sources
1754
+ known_ids = set(_PROVIDER_DISPLAY.keys()) | set(_PROVIDER_MODELS.keys())
1755
+
1756
+ # Also detect providers from config.yaml providers section
1757
+ cfg = get_config()
1758
+ providers_cfg = cfg.get("providers", {})
1759
+ if isinstance(providers_cfg, dict):
1760
+ known_ids.update(providers_cfg.keys())
1761
+
1762
+ # Add OAuth providers even if not in _PROVIDER_DISPLAY
1763
+ known_ids.update(_OAUTH_PROVIDERS)
1764
+
1765
+ for pid in sorted(known_ids):
1766
+ display_name = _PROVIDER_DISPLAY.get(pid, pid.replace("-", " ").title())
1767
+ is_oauth = _provider_is_oauth(pid)
1768
+ has_key = _provider_has_key(pid)
1769
+
1770
+ # Determine key source
1771
+ key_source = "none"
1772
+ auth_error = None
1773
+ if is_oauth:
1774
+ key_source = "oauth"
1775
+ # Check if actually authenticated via hermes_cli.
1776
+ # IMPORTANT: do not unconditionally overwrite has_key from _provider_has_key().
1777
+ # A token in config.yaml is a valid credential even when get_auth_status()
1778
+ # returns logged_in=False (e.g. token not in the hermes credential pool,
1779
+ # or refresh token consumed by native Codex CLI / VS Code extension).
1780
+ try:
1781
+ from hermes_cli.auth import get_auth_status as _gas
1782
+ status = _gas(pid)
1783
+ if isinstance(status, dict) and status.get("logged_in"):
1784
+ has_key = True
1785
+ key_source = status.get("key_source", "oauth")
1786
+ elif has_key:
1787
+ # _provider_has_key() found a token in config.yaml — respect it
1788
+ # rather than hiding a working credential from the Settings UI.
1789
+ key_source = "config_yaml"
1790
+ auth_error = status.get("error") if isinstance(status, dict) else None
1791
+ else:
1792
+ has_key = False
1793
+ auth_error = status.get("error") if isinstance(status, dict) else None
1794
+ except Exception:
1795
+ # Import failed or auth check errored — don't override a known-good
1796
+ # key just because the hermes_cli auth module is unavailable.
1797
+ logger.debug("hermes_cli auth check failed for %s", pid, exc_info=True)
1798
+ # keep has_key from _provider_has_key()
1799
+ elif has_key:
1800
+ env_var = _PROVIDER_ENV_VAR.get(pid)
1801
+ if env_var:
1802
+ env_path = _get_hermes_home() / ".env"
1803
+ env_values = _load_env_file(env_path)
1804
+ if _provider_value_counts_as_api_key(pid, env_values.get(env_var)):
1805
+ key_source = "env_file"
1806
+ elif _provider_value_counts_as_api_key(pid, os.getenv(env_var)):
1807
+ key_source = "env_var"
1808
+ else:
1809
+ # Canonical name not set; check legacy aliases (e.g. lmstudio's
1810
+ # pre-#1500 LMSTUDIO_API_KEY) so existing users see "env_file"
1811
+ # instead of being misreported as "config_yaml" when the key
1812
+ # actually lives in .env under the old name.
1813
+ aliased = False
1814
+ for alias in _PROVIDER_ENV_VAR_ALIASES.get(pid, ()) or ():
1815
+ if _provider_value_counts_as_api_key(pid, env_values.get(alias)):
1816
+ key_source = "env_file"
1817
+ aliased = True
1818
+ break
1819
+ if _provider_value_counts_as_api_key(pid, os.getenv(alias)):
1820
+ key_source = "env_var"
1821
+ aliased = True
1822
+ break
1823
+ if not aliased:
1824
+ key_source = "config_yaml"
1825
+ else:
1826
+ key_source = "config_yaml"
1827
+ elif pid not in _PROVIDER_ENV_VAR:
1828
+ # Fallback: provider is not a known API-key provider and not in
1829
+ # the hardcoded _OAUTH_PROVIDERS set. It may be a custom or
1830
+ # newly-added OAuth provider (e.g. Anthropic connected via OAuth).
1831
+ # Check live auth status so the Providers tab agrees with the
1832
+ # model picker (#1212).
1833
+ #
1834
+ # IMPORTANT: we skip providers in _PROVIDER_ENV_VAR because they
1835
+ # are pure API-key providers — calling get_auth_status() for every
1836
+ # unconfigured API-key provider would add unnecessary latency
1837
+ # (network round-trip per provider) on the Settings page.
1838
+ # Validate pid looks like a real provider before probing
1839
+ import re as _re
1840
+ if _re.match(r'^[a-z][a-z0-9_-]{0,63}$', pid):
1841
+ try:
1842
+ from hermes_cli.auth import get_auth_status as _gas
1843
+ status = _gas(pid)
1844
+ if isinstance(status, dict) and status.get("logged_in"):
1845
+ has_key = True
1846
+ # Constrain key_source to a known-safe closed set
1847
+ _raw_ks = status.get("key_source", "")
1848
+ key_source = _raw_ks if _raw_ks in {"oauth", "env", "config", "token"} else "oauth"
1849
+ is_oauth = True
1850
+ except Exception:
1851
+ pass
1852
+
1853
+ if pid == "openai" and not has_key and _provider_has_shadowed_codex_oauth_value(pid):
1854
+ continue
1855
+
1856
+ models = list(_PROVIDER_MODELS.get(pid, []))
1857
+ models_total = len(models)
1858
+ # OpenAI Codex account catalogs drift independently from WebUI releases.
1859
+ # The model picker already prefers hermes_cli + Codex local cache for
1860
+ # this provider (the agent's `provider_model_ids("openai-codex")` filters
1861
+ # IDs with `supported_in_api: false`, but Codex CLI still surfaces some
1862
+ # of those — notably `gpt-5.3-codex-spark` from #1680 — in its picker).
1863
+ # Merge both sources here so the providers card matches the picker
1864
+ # exactly. Static entries remain the offline fallback when live
1865
+ # discovery and the local Codex cache are both unavailable. (#1807
1866
+ # follow-up to v0.51.19 #1812.)
1867
+ if pid == "openai-codex":
1868
+ live_ids = _read_live_provider_model_ids("openai-codex")
1869
+ for mid in _read_visible_codex_cache_model_ids():
1870
+ if mid not in live_ids:
1871
+ live_ids.append(mid)
1872
+ live_models = _models_from_live_provider_ids(pid, live_ids)
1873
+ if live_models:
1874
+ models = live_models
1875
+ models_total = len(models)
1876
+ if pid == "xai-oauth":
1877
+ live_models = _models_from_live_provider_ids(
1878
+ pid,
1879
+ _read_live_provider_model_ids("xai-oauth"),
1880
+ )
1881
+ if live_models:
1882
+ models = live_models
1883
+ models_total = len(models)
1884
+ # Nous Portal: prefer the live catalog so the providers card matches
1885
+ # the dropdown picker (#1538). Same fallback shape as the static-only
1886
+ # case below — when hermes_cli is unavailable or its lookup raises,
1887
+ # we keep the four-entry curated list.
1888
+ #
1889
+ # On large-tier accounts (#1567 reporter Deor saw 396 entries), we
1890
+ # render the same featured subset the picker uses so the providers
1891
+ # card body doesn't become a 396-pill wall. The full count is still
1892
+ # reported via models_total — surfaced in the header line as
1893
+ # "396 models · OAuth" by static/panels.js — so the user knows the
1894
+ # complete catalog is reachable (via /model autocomplete or a future
1895
+ # "show all" disclosure if added).
1896
+ if pid == "nous":
1897
+ try:
1898
+ from hermes_cli.models import provider_model_ids as _provider_model_ids
1899
+
1900
+ live_ids = _provider_model_ids("nous") or []
1901
+ if live_ids:
1902
+ # Lazy-import to avoid circular dep with api.config.
1903
+ from api.config import _format_nous_label, _build_nous_featured_set
1904
+
1905
+ featured_ids, _extras = _build_nous_featured_set(live_ids)
1906
+ models = [
1907
+ {"id": f"@nous:{mid}", "label": _format_nous_label(mid)}
1908
+ for mid in featured_ids
1909
+ ]
1910
+ models_total = len(live_ids)
1911
+ except Exception:
1912
+ logger.debug("Failed to load Nous Portal models from hermes_cli")
1913
+ # LM Studio: fetch live locally-loaded models so the providers card
1914
+ # matches what's actually available on the user's server (#WebUI).
1915
+ if pid == "lmstudio":
1916
+ try:
1917
+ from hermes_cli.models import provider_model_ids as _pmi
1918
+
1919
+ lm_live = _pmi("lmstudio") or []
1920
+ if lm_live:
1921
+ models = [{"id": mid, "label": mid} for mid in lm_live]
1922
+ models_total = len(models)
1923
+ except Exception:
1924
+ logger.debug("Failed to load LM Studio models from hermes_cli")
1925
+ # Also include models from config.yaml providers section
1926
+ if isinstance(providers_cfg, dict):
1927
+ provider_cfg = providers_cfg.get(pid, {})
1928
+ if isinstance(provider_cfg, dict) and "models" in provider_cfg:
1929
+ cfg_models = provider_cfg["models"]
1930
+ if isinstance(cfg_models, dict):
1931
+ models = models + [{"id": k, "label": k} for k in cfg_models.keys()]
1932
+ elif isinstance(cfg_models, list):
1933
+ models = models + [{"id": k, "label": k} for k in cfg_models]
1934
+ # Recompute models_total when config.yaml contributes additional
1935
+ # entries on top of the live/static catalog. For non-Nous
1936
+ # providers models_total still equals len(models); for Nous
1937
+ # we keep the live count (which already includes any models
1938
+ # surfaced in the curated featured slice).
1939
+ if pid != "nous":
1940
+ models_total = len(models)
1941
+
1942
+ providers.append({
1943
+ "id": pid,
1944
+ "display_name": display_name,
1945
+ "has_key": has_key,
1946
+ "configurable": not is_oauth and pid in _PROVIDER_ENV_VAR,
1947
+ "is_oauth": is_oauth,
1948
+ "key_source": key_source,
1949
+ "auth_error": auth_error,
1950
+ "models": models,
1951
+ # models_total reflects the complete catalog size (e.g. 396 for
1952
+ # an enterprise Nous Portal account), even when "models" is
1953
+ # trimmed to a featured subset for UI scannability. The frontend
1954
+ # uses this for the header text "396 models · OAuth" so users
1955
+ # know the full catalog exists and is reachable via the slash
1956
+ # command. For providers that don't trim, models_total ==
1957
+ # len(models) and the frontend behaves identically to before.
1958
+ "models_total": models_total,
1959
+ })
1960
+
1961
+ # Scan custom_providers from config.yaml (e.g. glmcode, timicc)
1962
+ custom_providers_cfg = cfg.get("custom_providers", [])
1963
+ if isinstance(custom_providers_cfg, list):
1964
+ for cp in custom_providers_cfg:
1965
+ if not isinstance(cp, dict) or not cp.get("name"):
1966
+ continue
1967
+ cp_name = str(cp["name"]).strip()
1968
+ cp_id = _custom_provider_slug_from_name(cp_name)
1969
+ if not cp_id:
1970
+ logger.warning(
1971
+ "Custom provider entry %r produced empty slug; skipping",
1972
+ cp_name,
1973
+ )
1974
+ continue
1975
+ # Collect models from `models` list or `model` single
1976
+ cp_models = []
1977
+ if isinstance(cp.get("models"), list):
1978
+ cp_models = [{"id": str(m), "label": str(m)} for m in cp["models"]]
1979
+ elif cp.get("model"):
1980
+ cp_models = [{"id": cp["model"], "label": cp["model"]}]
1981
+ # Check for env var reference (${VAR_NAME} pattern)
1982
+ cp_api_key = str(cp.get("api_key") or "")
1983
+ cp_has_key = bool(cp_api_key.strip())
1984
+ # Replace env var reference to check actual value
1985
+ if cp_api_key.startswith("${") and cp_api_key.endswith("}"):
1986
+ env_var = cp_api_key[2:-1]
1987
+ cp_has_key = bool(os.getenv(env_var, "").strip())
1988
+ providers.append({
1989
+ "id": cp_id,
1990
+ "display_name": cp_name,
1991
+ "has_key": cp_has_key,
1992
+ "configurable": False, # custom providers managed via config.yaml
1993
+ "is_custom": True,
1994
+ "key_source": "config_yaml" if cp_has_key else "none",
1995
+ "models": cp_models,
1996
+ "models_total": len(cp_models),
1997
+ })
1998
+
1999
+ # Determine active provider
2000
+ active_provider = None
2001
+ model_cfg = cfg.get("model", {})
2002
+ if isinstance(model_cfg, dict):
2003
+ active_provider = model_cfg.get("provider")
2004
+
2005
+ # Sort providers: active first, then custom:*, then has_key, then rest.
2006
+ def _provider_sort_key(p):
2007
+ pid = p.get("id") or ""
2008
+ if pid == active_provider:
2009
+ return (0, pid)
2010
+ if pid.startswith("custom:"):
2011
+ return (1, pid)
2012
+ if p.get("has_key"):
2013
+ return (2, pid)
2014
+ return (3, pid)
2015
+ providers.sort(key=_provider_sort_key)
2016
+
2017
+ return {
2018
+ "providers": providers,
2019
+ "active_provider": active_provider,
2020
+ }
2021
+
2022
+
2023
+ def set_provider_key(provider_id: str, api_key: str | None) -> dict[str, Any]:
2024
+ """Set or update the API key for a provider.
2025
+
2026
+ Writes the key to ``~/.hermes/.env`` using the standard env var name.
2027
+ If ``api_key`` is None or empty, the key is removed.
2028
+
2029
+ Returns a status dict with the operation result.
2030
+ """
2031
+ provider_id = provider_id.strip().lower()
2032
+
2033
+ if not provider_id:
2034
+ return {"ok": False, "error": "Provider ID is required."}
2035
+
2036
+ if _provider_is_oauth(provider_id):
2037
+ return {
2038
+ "ok": False,
2039
+ "error": f"'{_PROVIDER_DISPLAY.get(provider_id, provider_id)}' uses OAuth authentication. "
2040
+ f"Use `hermes model` in the terminal to configure it.",
2041
+ }
2042
+
2043
+ env_var = _PROVIDER_ENV_VAR.get(provider_id)
2044
+ if not env_var:
2045
+ return {
2046
+ "ok": False,
2047
+ "error": f"Cannot configure API key for '{_PROVIDER_DISPLAY.get(provider_id, provider_id)}'. "
2048
+ f"This provider does not have a known env var mapping.",
2049
+ }
2050
+
2051
+ # Validate API key format (basic sanity check)
2052
+ if api_key:
2053
+ api_key = api_key.strip()
2054
+ if "\n" in api_key or "\r" in api_key:
2055
+ return {"ok": False, "error": "API key must not contain newline characters."}
2056
+ if len(api_key) < 8:
2057
+ return {"ok": False, "error": "API key appears too short."}
2058
+
2059
+ env_path = _get_hermes_home() / ".env"
2060
+ try:
2061
+ _write_env_file(env_path, {env_var: api_key})
2062
+ except ValueError as exc:
2063
+ return {"ok": False, "error": str(exc)}
2064
+ except Exception as exc:
2065
+ logger.exception("Failed to write env file for provider %s", provider_id)
2066
+ return {"ok": False, "error": f"Failed to save API key: {exc}"}
2067
+
2068
+ # Invalidate the model cache so the dropdown refreshes on next request.
2069
+ # Using invalidate_models_cache() instead of reload_config() to avoid
2070
+ # disrupting active streaming sessions that may be reading config.cfg.
2071
+ invalidate_models_cache()
2072
+
2073
+ return {
2074
+ "ok": True,
2075
+ "provider": provider_id,
2076
+ "display_name": _PROVIDER_DISPLAY.get(provider_id, provider_id),
2077
+ "action": "updated" if api_key else "removed",
2078
+ }
2079
+
2080
+
2081
+ def remove_provider_key(provider_id: str) -> dict[str, Any]:
2082
+ """Remove the API key for a provider.
2083
+
2084
+ Removes the key from ``~/.hermes/.env`` (via ``set_provider_key``)
2085
+ and also cleans up ``config.yaml`` if the key is stored there
2086
+ (``providers.<id>.api_key`` or top-level ``model.api_key`` when this
2087
+ provider is the active one).
2088
+
2089
+ Returns a status dict with the operation result.
2090
+ """
2091
+ result = set_provider_key(provider_id, None)
2092
+
2093
+ # Even if the .env removal succeeded, the key might also live in
2094
+ # config.yaml (e.g. providers.<id>.api_key or model.api_key).
2095
+ # Clean those up so _provider_has_key() returns False after removal.
2096
+ if result.get("ok"):
2097
+ _clean_provider_key_from_config(provider_id)
2098
+
2099
+ return result
2100
+
2101
+
2102
+ def _clean_provider_key_from_config(provider_id: str) -> None:
2103
+ """Remove provider API key entries from config.yaml.
2104
+
2105
+ Handles three storage locations:
2106
+ 1. ``providers.<id>.api_key`` — per-provider key
2107
+ 2. ``model.api_key`` — top-level key (only if provider is active)
2108
+ 3. ``custom_providers[].api_key`` — custom provider entries
2109
+
2110
+ Writes back to config.yaml only if something was actually removed.
2111
+ Uses ``_cfg_lock`` to prevent TOCTOU races.
2112
+ """
2113
+ from api.config import _cfg_lock
2114
+
2115
+ try:
2116
+ # Resolve through api.config at call time instead of the function imported
2117
+ # at module load. Several tests (and some profile flows) monkeypatch the
2118
+ # config module's path resolver after api.providers has already been
2119
+ # imported; using the stale imported reference can clean the wrong
2120
+ # config.yaml.
2121
+ import api.config as _config
2122
+ config_path = _config._get_config_path()
2123
+ except Exception:
2124
+ return
2125
+
2126
+ if not config_path.exists():
2127
+ return
2128
+
2129
+ try:
2130
+ import yaml as _yaml
2131
+
2132
+ changed = False
2133
+
2134
+ with _cfg_lock:
2135
+ raw = config_path.read_text(encoding="utf-8")
2136
+ cfg = _yaml.safe_load(raw)
2137
+ if not isinstance(cfg, dict):
2138
+ return
2139
+
2140
+ # 1. Clean providers.<id>.api_key
2141
+ providers_cfg = cfg.get("providers", {})
2142
+ if isinstance(providers_cfg, dict):
2143
+ provider_cfg = providers_cfg.get(provider_id, {})
2144
+ if isinstance(provider_cfg, dict) and provider_cfg.get("api_key"):
2145
+ del provider_cfg["api_key"]
2146
+ changed = True
2147
+
2148
+ # 2. Clean model.api_key — only if this provider is the active one
2149
+ model_cfg = cfg.get("model", {})
2150
+ if isinstance(model_cfg, dict) and model_cfg.get("api_key"):
2151
+ active_provider = model_cfg.get("provider")
2152
+ if active_provider and str(active_provider).strip().lower() == provider_id.lower():
2153
+ del model_cfg["api_key"]
2154
+ changed = True
2155
+
2156
+ # 3. Clean custom_providers[].api_key
2157
+ custom_providers = cfg.get("custom_providers", [])
2158
+ if isinstance(custom_providers, list):
2159
+ for cp in custom_providers:
2160
+ if isinstance(cp, dict):
2161
+ if _custom_provider_name_matches(provider_id, cp.get("name")):
2162
+ if cp.get("api_key"):
2163
+ del cp["api_key"]
2164
+ changed = True
2165
+
2166
+ if changed:
2167
+ _save_yaml_config_file(config_path, cfg)
2168
+ # Sync in-memory cache and bust model TTL cache
2169
+ # MUST be called outside _cfg_lock to avoid deadlock:
2170
+ # _cfg_lock is a threading.Lock (non-reentrant) and
2171
+ # reload_config() also acquires _cfg_lock internally.
2172
+ if changed:
2173
+ reload_config()
2174
+ except Exception:
2175
+ logger.exception("Failed to clean provider key from config.yaml for %s", provider_id)