create-workframe 0.1.11 → 0.1.13

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 (195) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +2 -2
  3. package/bin/workframe.js +130 -2
  4. package/docs/security.md +85 -0
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +9 -0
  8. package/scripts/verify-public-deploy.sh +121 -3
  9. package/workframe-api/action_proxy.py +23 -17
  10. package/workframe-api/activity_feed.py +798 -0
  11. package/workframe-api/api_meta.py +157 -0
  12. package/workframe-api/auth_gate.py +566 -0
  13. package/workframe-api/avatar_registry.py +343 -0
  14. package/workframe-api/broker_audit.py +164 -0
  15. package/workframe-api/cell_authority.py +231 -0
  16. package/workframe-api/chat_bind.py +319 -0
  17. package/workframe-api/chat_sessions.py +509 -0
  18. package/workframe-api/chat_stream.py +694 -0
  19. package/workframe-api/cleanup_dogfood_smoke.py +252 -0
  20. package/workframe-api/credential_broker.py +162 -0
  21. package/workframe-api/credential_resolve.py +202 -0
  22. package/workframe-api/credential_store.py +245 -0
  23. package/workframe-api/crew_registry.py +250 -0
  24. package/workframe-api/db_schema.py +755 -0
  25. package/workframe-api/docker_gateway.py +166 -0
  26. package/workframe-api/doctor_runtime.py +109 -0
  27. package/workframe-api/domain/__init__.py +47 -0
  28. package/workframe-api/domain/entities.py +252 -0
  29. package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
  30. package/workframe-api/egress_policy.py +42 -0
  31. package/workframe-api/handler_modules/__init__.py +17 -0
  32. package/workframe-api/handler_modules/handler_admin.py +542 -0
  33. package/workframe-api/handler_modules/handler_auth.py +512 -0
  34. package/workframe-api/handler_modules/handler_chat.py +641 -0
  35. package/workframe-api/handler_modules/handler_install.py +178 -0
  36. package/workframe-api/handler_modules/handler_provider.py +325 -0
  37. package/workframe-api/handler_modules/handler_workspace.py +1420 -0
  38. package/workframe-api/health_monitor.py +80 -0
  39. package/workframe-api/hermes_admin.py +756 -0
  40. package/workframe-api/hermes_profiles.py +1328 -0
  41. package/workframe-api/install_api.py +123 -0
  42. package/workframe-api/kanban_cron.py +473 -0
  43. package/workframe-api/lane_bindings.py +423 -0
  44. package/workframe-api/llm_proxy.py +17 -43
  45. package/workframe-api/mention_helpers.py +180 -0
  46. package/workframe-api/mention_invoke.py +431 -0
  47. package/workframe-api/model_surface.py +1139 -0
  48. package/workframe-api/oauth_pending.py +85 -0
  49. package/workframe-api/oauth_redirect.py +381 -0
  50. package/workframe-api/openrouter_catalog.py +4 -4
  51. package/workframe-api/package.json +2 -2
  52. package/workframe-api/profile_api_lifecycle.py +66 -0
  53. package/workframe-api/profile_config_yaml.py +126 -0
  54. package/workframe-api/profile_gateway.py +436 -0
  55. package/workframe-api/provider_bindings.py +673 -0
  56. package/workframe-api/provider_bootstrap.py +347 -0
  57. package/workframe-api/provider_catalog.py +180 -0
  58. package/workframe-api/rooms.py +1613 -0
  59. package/workframe-api/route_registry.py +470 -0
  60. package/workframe-api/run-typecheck.mjs +26 -0
  61. package/workframe-api/run_authority.py +287 -0
  62. package/workframe-api/run_ledger.py +470 -0
  63. package/workframe-api/run_surface_wiring.py +344 -0
  64. package/workframe-api/runtime_cohort.py +752 -0
  65. package/workframe-api/runtime_tokens.py +127 -0
  66. package/workframe-api/server.py +1376 -19305
  67. package/workframe-api/snapshot_feed.py +49 -0
  68. package/workframe-api/stack_config.py +33 -1
  69. package/workframe-api/supervisor_client.py +154 -0
  70. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  71. package/workframe-api/test_cell_authority.py +79 -0
  72. package/workframe-api/test_chat_bind.py +48 -0
  73. package/workframe-api/test_credential_lease_matrix.py +171 -0
  74. package/workframe-api/test_credential_resolve.py +51 -0
  75. package/workframe-api/test_credential_store.py +27 -0
  76. package/workframe-api/test_dogfood_flows.py +346 -0
  77. package/workframe-api/test_domain_entities.py +152 -0
  78. package/workframe-api/test_exception_hygiene.py +12 -0
  79. package/workframe-api/test_hermes_admin.py +72 -0
  80. package/workframe-api/test_install_wizard_resume.py +78 -0
  81. package/workframe-api/test_local_bootstrap.py +67 -0
  82. package/workframe-api/test_mention_helpers.py +35 -0
  83. package/workframe-api/test_mention_invoke.py +26 -0
  84. package/workframe-api/test_model_surface_consistency.py +117 -0
  85. package/workframe-api/test_oauth_pending.py +41 -0
  86. package/workframe-api/test_profile_model_yaml.py +99 -0
  87. package/workframe-api/test_provider_bindings.py +52 -0
  88. package/workframe-api/test_provider_catalog.py +28 -0
  89. package/workframe-api/test_public_routes.py +41 -0
  90. package/workframe-api/test_route_registry.py +245 -0
  91. package/workframe-api/test_run_authority.py +112 -0
  92. package/workframe-api/test_run_ledger.py +157 -0
  93. package/workframe-api/test_run_surface_wiring.py +130 -0
  94. package/workframe-api/test_runtime_cohort.py +85 -0
  95. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  96. package/workframe-api/test_server_reexports.py +99 -0
  97. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  98. package/workframe-api/time-bind-steps.py +75 -0
  99. package/workframe-api/time-perf.py +81 -0
  100. package/workframe-api/time-warm.py +107 -0
  101. package/workframe-api/turn_credentials.py +28 -13
  102. package/workframe-api/turn_overlay.py +715 -0
  103. package/workframe-api/updates.py +16 -1
  104. package/workframe-api/user_prefs.py +387 -0
  105. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  106. package/workframe-api/workspace_bootstrap.py +536 -0
  107. package/workframe-api/workspace_files.py +344 -0
  108. package/workframe-api/workspace_messaging.py +206 -0
  109. package/workframe-api/zk_auth.py +3 -2
  110. package/workframe-supervisor/server.py +10 -1
  111. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  112. package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
  113. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  114. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  115. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  116. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  117. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  118. package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  120. package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  123. package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  124. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  127. package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  128. package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  129. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  130. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  131. package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  132. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  133. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  134. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  135. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  136. package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  137. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  138. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  139. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  140. package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  141. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  142. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  143. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  144. package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  145. package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  146. package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
  147. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  148. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  149. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  150. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  151. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  152. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  153. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  154. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  155. package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  156. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  157. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  158. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  159. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  160. package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
  161. package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
  162. package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  163. package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
  164. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  165. package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  166. package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  167. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  168. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  169. package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  170. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  171. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  172. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  173. package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
  174. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  175. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  176. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  177. package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  178. package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  179. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  180. package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  181. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  182. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  183. package/workframe-ui/public/index.html +7 -6
  184. package/workframe-ui/public/workframe-build.json +5 -0
  185. package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
  186. package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
  187. package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
  188. package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
  189. package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
  190. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
  191. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
  192. package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
  193. package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
  194. package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
  195. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
@@ -0,0 +1,347 @@
1
+ """WF-032 extract: provider_bootstrap."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import queue
7
+ import re
8
+ import secrets
9
+ import shlex
10
+ import shutil
11
+ import sqlite3
12
+ import threading
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ import uuid
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from http.server import BaseHTTPRequestHandler
22
+
23
+ import user_prefs
24
+
25
+
26
+ def _srv():
27
+ import server as srv
28
+
29
+ return srv
30
+
31
+
32
+ def _load_profile_auth_json(profile: str) -> dict[str, Any]:
33
+ path = _srv()._profile_dir(profile) / "auth.json"
34
+ fallback: dict[str, Any] = {"version": 1, "providers": {}, "credential_pool": {}}
35
+ if not path.is_file():
36
+ return dict(fallback)
37
+ try:
38
+ raw = json.loads(path.read_text(encoding="utf-8"))
39
+ except (OSError, json.JSONDecodeError):
40
+ return dict(fallback)
41
+ return raw if isinstance(raw, dict) else dict(fallback)
42
+
43
+
44
+ def _provider_pool_entry(provider: str, env_var: str, *, base_url: str = "") -> dict[str, Any]:
45
+ base_urls = {
46
+ "openrouter": "https://openrouter.ai/api/v1",
47
+ "openai": "https://api.openai.com/v1",
48
+ "anthropic": "https://api.anthropic.com",
49
+ }
50
+ resolved_base = str(base_url or "").strip() or base_urls.get(provider.lower(), "")
51
+ return {
52
+ "id": secrets.token_hex(3),
53
+ "label": env_var,
54
+ "auth_type": "api_key",
55
+ "priority": 0,
56
+ "source": f"env:{env_var}",
57
+ "last_status": "ok",
58
+ "base_url": resolved_base,
59
+ "request_count": 0,
60
+ }
61
+
62
+
63
+ def _runtime_provider_pool_entry(provider: str, env_var: str) -> dict[str, Any]:
64
+ """Credential pool row for u-* profiles — lease token lives in OPENAI_API_KEY."""
65
+ lease_env = "OPENAI_API_KEY"
66
+ return _provider_pool_entry(provider, lease_env, base_url=_srv()._llm_proxy_base_url(provider))
67
+
68
+
69
+ def _provider_pool_has_entries(auth: dict[str, Any], provider: str) -> bool:
70
+ pool = auth.get("credential_pool")
71
+ if not isinstance(pool, dict):
72
+ return False
73
+ provider_key = str(provider or "").strip().lower()
74
+ for key in _srv()._hermes_oauth_auth_keys(provider_key) | {provider_key}:
75
+ entries = pool.get(key)
76
+ if isinstance(entries, list) and len(entries) > 0:
77
+ return True
78
+ spec = _srv()._catalog_provider_for_llm(provider_key)
79
+ if spec:
80
+ auth_id = str(spec.get("hermes_auth_id") or "").strip().lower()
81
+ if auth_id:
82
+ entries = pool.get(auth_id)
83
+ if isinstance(entries, list) and len(entries) > 0:
84
+ return True
85
+ return False
86
+
87
+
88
+ def _connected_provider_names(user_id: str = "", workspace_id: str = "") -> set[str]:
89
+ names: set[str] = set()
90
+ user = str(user_id or "").strip()
91
+ workspace = str(workspace_id or "").strip()
92
+ if user:
93
+ names.update(_srv()._user_provider_bindings(user).keys())
94
+ names.update(
95
+ str(spec["id"]).lower()
96
+ for spec in _srv().PROVIDER_CONNECT_CATALOG
97
+ if str(spec.get("env_var") or "") in _srv()._user_auth_env_keys(user)
98
+ )
99
+ if workspace:
100
+ for provider in ("openrouter", "openai", "anthropic", "google"):
101
+ if _srv()._resolve_credential("", workspace, provider):
102
+ names.add(provider)
103
+ if not user:
104
+ primary = _srv()._primary_profile()
105
+ if primary:
106
+ primary_pool = _load_profile_auth_json(primary).get("credential_pool")
107
+ if isinstance(primary_pool, dict):
108
+ for pname, entries in primary_pool.items():
109
+ if isinstance(entries, list) and entries:
110
+ names.add(str(pname).lower())
111
+ if not names:
112
+ names.add("openrouter")
113
+ return names
114
+
115
+
116
+ def _user_llm_providers_for_picker(user_id: str) -> set[str]:
117
+ user = str(user_id or "").strip()
118
+ if not user:
119
+ return set()
120
+ now = time.monotonic()
121
+ cached = _srv()._user_llm_picker_cache.get(user)
122
+ if cached and now - cached[1] < _srv()._USER_LLM_PICKER_TTL_SEC:
123
+ return set(cached[0])
124
+ connected = frozenset(
125
+ str(spec["id"]).lower()
126
+ for spec in _srv().PROVIDER_CONNECT_CATALOG
127
+ if str(spec.get("category") or "") == "llm" and _srv()._user_provider_connected(user, spec)
128
+ )
129
+ _srv()._user_llm_picker_cache[user] = (connected, now)
130
+ return set(connected)
131
+
132
+
133
+ def _user_has_llm_provider(user_id: str) -> bool:
134
+ return bool(_srv()._user_llm_providers_for_picker(user_id))
135
+
136
+
137
+ def _user_llm_has_provider(user_id: str, workspace_id: str = "") -> bool:
138
+ if _user_has_llm_provider(user_id):
139
+ return True
140
+ ws = str(workspace_id or "").strip()
141
+ return bool(ws and _workspace_has_llm_provider(ws))
142
+
143
+
144
+ def _workspace_has_llm_provider(workspace_id: str) -> bool:
145
+ workspace_id = str(workspace_id or "").strip()
146
+ if not workspace_id:
147
+ return False
148
+ for spec in _srv().PROVIDER_CONNECT_CATALOG:
149
+ if str(spec.get("category") or "") != "llm":
150
+ continue
151
+ provider_id = str(spec["id"])
152
+ resolved = _srv()._resolve_credential("", workspace_id, provider_id)
153
+ if not resolved:
154
+ continue
155
+ env_var = str(spec.get("env_var") or "")
156
+ if env_var and _srv()._stack_profile_env().get(env_var):
157
+ return True
158
+ return False
159
+
160
+
161
+ def _user_can_use_llm(user_id: str, workspace_id: str = "", provider: str = "openrouter") -> bool:
162
+ user = str(user_id or "").strip()
163
+ if not user:
164
+ return False
165
+ spec = _srv()._catalog_provider_for_llm(provider)
166
+ if spec and str(spec.get("connect_mode") or "") == "oauth":
167
+ return _srv()._hermes_oauth_tokens_present(user, _srv()._hermes_auth_id_for_spec(spec))
168
+ try:
169
+ resolved = _srv()._require_user_provider(user, workspace_id, provider)
170
+ return bool(_srv()._credential_secret(resolved, user))
171
+ except ValueError:
172
+ return False
173
+
174
+
175
+ def _overlay_chat_llm_env(
176
+ hermes_prof: str,
177
+ user_id: str,
178
+ workspace_id: str,
179
+ provider: str,
180
+ ) -> bool:
181
+ """Overlay turn credentials; True when this turn can bill the acting user's key."""
182
+ user = str(user_id or "").strip()
183
+ if not user:
184
+ return False
185
+ prov = _srv()._llm_billing_provider(hermes_prof, provider, user_id=user, workspace_id=workspace_id)
186
+ if not _srv()._profile_llm_proxy_matches_billing(hermes_prof, prov):
187
+ _srv()._ensure_profile_llm_proxy(hermes_prof, prov)
188
+ slug = _srv().safe_profile_slug(hermes_prof)
189
+ turn_run = str(uuid.uuid4())
190
+ ready = _srv()._try_overlay_turn_provider_env(hermes_prof, user, workspace_id, prov, turn_run)
191
+ if ready:
192
+ _srv()._overlay_turn_user_env(hermes_prof, user, workspace_id, turn_run)
193
+ return True
194
+ if _srv()._is_runtime_profile_slug(slug):
195
+ return False
196
+ return _srv()._user_can_use_llm(user, workspace_id, prov)
197
+
198
+
199
+
200
+ def _reconcile_profile_llm_for_user(
201
+ profile: str,
202
+ user_id: str,
203
+ workspace_id: str,
204
+ *,
205
+ prefer_provider: str = "",
206
+ ) -> bool:
207
+ """Point proxy + MVP model at a provider the acting user can bill."""
208
+ user = str(user_id or "").strip()
209
+ if not user:
210
+ return False
211
+ prof = _srv().resolve_hermes_profile(profile)
212
+ block = _srv()._read_model_block(prof)
213
+ model = str(block.get("default") or "").strip()
214
+ if model:
215
+ cfg = str(block.get("provider") or "").strip().lower()
216
+ quick_billing = _srv()._billing_provider_id_from_hermes_config(cfg)
217
+ # Direct oauth routing only — custom proxy may still need rebilling to user's provider.
218
+ if quick_billing and cfg != "custom" and _srv()._profile_routing_matches_billing(
219
+ prof, quick_billing, block=block,
220
+ ):
221
+ return False
222
+ connected = _srv()._user_llm_providers_for_picker(user)
223
+ current_model = model
224
+ prefer = str(prefer_provider or "").strip().lower()
225
+ model_billing = (
226
+ _srv()._resolve_billing_provider_for_model(current_model, connected, prefer=prefer)
227
+ if current_model
228
+ else ""
229
+ )
230
+ if model_billing and _srv()._user_can_use_llm(user, workspace_id, model_billing):
231
+ billing = model_billing
232
+ elif prefer and _srv()._user_can_use_llm(user, workspace_id, prefer):
233
+ billing = prefer
234
+ else:
235
+ billing = _srv()._llm_billing_provider(prof, user_id=user, workspace_id=workspace_id)
236
+ if not _srv()._user_can_use_llm(user, workspace_id, billing):
237
+ connected_sorted = sorted(connected)
238
+ if not connected_sorted:
239
+ return False
240
+ billing = connected_sorted[0]
241
+ if _srv()._profile_routing_matches_billing(prof, billing):
242
+ return False
243
+ if _srv()._oauth_llm_provider_spec(billing) and _srv()._user_can_use_llm(user, workspace_id, billing):
244
+ current = str(_srv()._read_model_from_config(prof)[1] or "").strip()
245
+ if not _srv()._resolve_billing_provider_for_model(current, {billing}, prefer=billing):
246
+ current = str((_srv().PROVIDER_MVP_MODELS.get(billing) or {}).get("primary") or "")
247
+ _srv()._apply_model_for_billing_provider(prof, billing, current, user_id=user)
248
+ if _srv()._is_runtime_profile_slug(prof):
249
+ _srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
250
+ return True
251
+ if _srv()._user_can_use_llm(user, workspace_id, billing) and _srv()._profile_llm_proxy_matches_billing(prof, billing):
252
+ return False
253
+ changed = _srv()._apply_mvp_model_for_provider(prof, billing)
254
+ block = _srv()._read_model_block(prof)
255
+ if _srv()._is_runtime_profile_slug(prof) or str(block.get("provider") or "").strip().lower() == "custom":
256
+ _srv()._ensure_profile_llm_proxy(prof, billing)
257
+ if changed and _srv()._is_runtime_profile_slug(prof):
258
+ _srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
259
+ return changed
260
+
261
+
262
+ def _ensure_profile_auth_pool(profile: str, user_id: str = "", workspace_id: str = "") -> bool:
263
+ """Sync auth.json credential pool — does not mutate model yaml (safe on GET /models)."""
264
+ prof = _srv().resolve_hermes_profile(profile)
265
+ if prof == _srv()._primary_profile():
266
+ return False
267
+ if _srv()._is_runtime_profile_slug(prof):
268
+ _srv()._strip_profile_llm_env(prof)
269
+ _srv()._strip_profile_action_env(prof)
270
+ else:
271
+ _srv()._strip_profile_llm_env(prof)
272
+ _srv()._strip_profile_action_env(prof)
273
+ _srv()._sync_profile_provider_env(prof, user_id, workspace_id)
274
+
275
+ llm_provider = _srv()._llm_billing_provider(prof, user_id=user_id, workspace_id=workspace_id)
276
+ auth_path = _srv()._profile_dir(prof) / "auth.json"
277
+ auth = _load_profile_auth_json(prof)
278
+ if _provider_pool_has_entries(auth, llm_provider):
279
+ return False
280
+
281
+ user = str(user_id or "").strip()
282
+ llm_spec = _srv()._catalog_provider_for_llm(llm_provider)
283
+ if (
284
+ llm_spec
285
+ and str(llm_spec.get("connect_mode") or "") == "oauth"
286
+ and user
287
+ and _srv()._sync_oauth_llm_to_profile(prof, user, llm_provider)
288
+ ):
289
+ return True
290
+
291
+ if _srv()._is_runtime_profile_slug(prof):
292
+ if not user or not _srv()._user_can_use_llm(user, workspace_id, llm_provider):
293
+ return False
294
+ env_var = _srv()._provider_env_var(llm_provider)
295
+ template = _runtime_provider_pool_entry(llm_provider, env_var)
296
+ else:
297
+ resolved = _srv()._resolve_credential(
298
+ user,
299
+ str(workspace_id or "").strip(),
300
+ llm_provider,
301
+ user_only=_srv()._provider_user_only(llm_provider),
302
+ )
303
+ if not resolved:
304
+ return False
305
+
306
+ ref = str(resolved.get("credential_ref") or "")
307
+ env_var = str(resolved.get("env_var") or "") or (ref[4:] if ref.startswith("env:") else _srv()._provider_env_var(llm_provider))
308
+ template = _provider_pool_entry(llm_provider, env_var)
309
+
310
+ pool = auth.setdefault("credential_pool", {})
311
+ if not isinstance(pool, dict):
312
+ pool = {}
313
+ auth["credential_pool"] = pool
314
+ pool[llm_provider] = [template]
315
+ auth["version"] = 1
316
+ auth["providers"] = auth.get("providers") if isinstance(auth.get("providers"), dict) else {}
317
+ auth["updated_at"] = _srv()._utc_now()
318
+ auth_path.parent.mkdir(parents=True, exist_ok=True)
319
+ auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
320
+ _srv()._publish_profile_gateway_secrets(prof)
321
+ return True
322
+
323
+
324
+ def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id: str = "") -> bool:
325
+ """Seed profile auth + MVP model config from user, workspace, or primary stack."""
326
+ prof = _srv().resolve_hermes_profile(profile)
327
+ if prof == _srv()._primary_profile():
328
+ return False
329
+
330
+ changed = _srv()._ensure_profile_auth_pool(prof, user_id, workspace_id)
331
+
332
+ provider, model_name = _srv()._read_model_from_config(prof)
333
+ llm_provider = _srv()._llm_billing_provider(prof, provider or "")
334
+ if not str(model_name or "").strip():
335
+ changed = _srv()._apply_mvp_model_for_provider(prof, llm_provider) or changed
336
+ if _srv()._is_runtime_profile_slug(prof):
337
+ _srv()._ensure_profile_llm_proxy(prof, llm_provider)
338
+ elif not str(_srv()._read_model_from_config(prof)[0] or "").strip():
339
+ ok_provider, _ = _srv()._set_profile_model_provider(prof, llm_provider)
340
+ changed = ok_provider or changed
341
+ if _srv()._is_runtime_profile_slug(prof):
342
+ _srv()._ensure_profile_llm_proxy(prof, llm_provider)
343
+
344
+ if _srv()._is_runtime_profile_slug(prof):
345
+ _srv()._prepare_runtime_profile_credentials(prof, user_id, workspace_id)
346
+
347
+ return changed
@@ -0,0 +1,180 @@
1
+ """WF-032 extract: curated provider connect catalog and lookups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ # ponytail: curated Hermes-native provider list; env vars match configuration docs.
8
+ PROVIDER_CONNECT_CATALOG: tuple[dict[str, Any], ...] = (
9
+ {
10
+ "id": "openrouter",
11
+ "label": "OpenRouter",
12
+ "category": "llm",
13
+ "connect_mode": "api_key",
14
+ "env_var": "OPENROUTER_API_KEY",
15
+ "description": "Primary LLM router — any model via one API key.",
16
+ },
17
+ {
18
+ "id": "anthropic",
19
+ "label": "Anthropic",
20
+ "category": "llm",
21
+ "connect_mode": "api_key",
22
+ "env_var": "ANTHROPIC_API_KEY",
23
+ "description": "Direct Claude API access.",
24
+ },
25
+ {
26
+ "id": "openai",
27
+ "label": "OpenAI",
28
+ "category": "llm",
29
+ "connect_mode": "api_key",
30
+ "env_var": "OPENAI_API_KEY",
31
+ "description": "Direct OpenAI API access.",
32
+ },
33
+ {
34
+ "id": "codex",
35
+ "label": "OpenAI Codex",
36
+ "category": "llm",
37
+ "connect_mode": "oauth",
38
+ "hermes_auth_id": "openai-codex",
39
+ "description": "Codex CLI OAuth session (hermes auth add openai-codex).",
40
+ },
41
+ {
42
+ "id": "google",
43
+ "label": "Google Gemini",
44
+ "category": "llm",
45
+ "connect_mode": "api_key",
46
+ "env_var": "GEMINI_API_KEY",
47
+ "description": "Gemini models via API key.",
48
+ },
49
+ {
50
+ "id": "deepseek",
51
+ "label": "DeepSeek",
52
+ "category": "llm",
53
+ "connect_mode": "api_key",
54
+ "env_var": "DEEPSEEK_API_KEY",
55
+ "description": "DeepSeek chat models.",
56
+ },
57
+ {
58
+ "id": "brave",
59
+ "label": "Brave Search",
60
+ "category": "search",
61
+ "connect_mode": "api_key",
62
+ "env_var": "BRAVE_API_KEY",
63
+ "description": "Web search API for agent tools.",
64
+ },
65
+ {
66
+ "id": "nous",
67
+ "label": "Nous Portal",
68
+ "category": "llm",
69
+ "connect_mode": "oauth",
70
+ "hermes_auth_id": "nous",
71
+ "description": "OAuth bundle for models + tool gateway (hermes setup --portal).",
72
+ },
73
+ {
74
+ "id": "discord",
75
+ "label": "Discord",
76
+ "category": "messaging",
77
+ "connect_mode": "bot_token",
78
+ "env_var": "DISCORD_BOT_TOKEN",
79
+ "description": "Bot token from the Discord Developer Portal.",
80
+ },
81
+ {
82
+ "id": "telegram",
83
+ "label": "Telegram",
84
+ "category": "messaging",
85
+ "connect_mode": "bot_token",
86
+ "env_var": "TELEGRAM_BOT_TOKEN",
87
+ "description": "Bot token from @BotFather.",
88
+ },
89
+ {
90
+ "id": "slack",
91
+ "label": "Slack",
92
+ "category": "messaging",
93
+ "connect_mode": "bot_token",
94
+ "env_var": "SLACK_BOT_TOKEN",
95
+ "extra_env_vars": ("SLACK_APP_TOKEN",),
96
+ "description": "Slack bot + app tokens (Socket Mode).",
97
+ },
98
+ {
99
+ "id": "github",
100
+ "label": "GitHub",
101
+ "category": "dev",
102
+ "connect_mode": "oauth",
103
+ "env_var": "GITHUB_TOKEN",
104
+ "oauth_provider": "github",
105
+ "hermes_auth_id": "github",
106
+ "user_only": True,
107
+ "description": "OAuth (admin registers app) or fine-grained PAT — repo push and GitHub API.",
108
+ },
109
+ {
110
+ "id": "stripe",
111
+ "label": "Stripe",
112
+ "category": "payments",
113
+ "connect_mode": "oauth",
114
+ "env_var": "STRIPE_SECRET_KEY",
115
+ "oauth_provider": "stripe",
116
+ "user_only": True,
117
+ "description": "Connect your Stripe account — charges, customers, and billing tools.",
118
+ },
119
+ {
120
+ "id": "vercel",
121
+ "label": "Vercel",
122
+ "category": "dev",
123
+ "connect_mode": "api_key",
124
+ "env_var": "VERCEL_TOKEN",
125
+ "user_only": True,
126
+ "description": "Personal access token (VERCEL_TOKEN) for deploy/CLI tools.",
127
+ },
128
+ {
129
+ "id": "netlify",
130
+ "label": "Netlify",
131
+ "category": "dev",
132
+ "connect_mode": "api_key",
133
+ "env_var": "NETLIFY_AUTH_TOKEN",
134
+ "user_only": True,
135
+ "description": "Netlify personal access token (NETLIFY_AUTH_TOKEN).",
136
+ },
137
+ )
138
+
139
+ # Providers where agent actions must use the triggering user's credential only.
140
+ _USER_ONLY_PROVIDER_IDS: frozenset[str] = frozenset(
141
+ str(spec["id"]).lower()
142
+ for spec in PROVIDER_CONNECT_CATALOG
143
+ if spec.get("user_only")
144
+ )
145
+
146
+
147
+ def catalog_provider(provider_id: str) -> dict[str, Any] | None:
148
+ needle = str(provider_id or "").strip().lower()
149
+ for row in PROVIDER_CONNECT_CATALOG:
150
+ if str(row.get("id", "")).lower() == needle:
151
+ return row
152
+ return None
153
+
154
+
155
+ def catalog_provider_for_llm(llm_provider: str) -> dict[str, Any] | None:
156
+ spec = catalog_provider(llm_provider)
157
+ if spec:
158
+ return spec
159
+ needle = str(llm_provider or "").strip().lower()
160
+ for row in PROVIDER_CONNECT_CATALOG:
161
+ auth_id = str(row.get("hermes_auth_id") or row.get("id") or "").strip().lower()
162
+ if auth_id == needle:
163
+ return row
164
+ return None
165
+
166
+
167
+ def provider_user_only(provider_id: str) -> bool:
168
+ return str(provider_id or "").strip().lower() in _USER_ONLY_PROVIDER_IDS
169
+
170
+
171
+ def provider_env_vars(spec: dict[str, Any]) -> list[str]:
172
+ names: list[str] = []
173
+ primary = str(spec.get("env_var") or "").strip()
174
+ if primary:
175
+ names.append(primary)
176
+ for extra in spec.get("extra_env_vars") or ():
177
+ key = str(extra or "").strip()
178
+ if key and key not in names:
179
+ names.append(key)
180
+ return names