create-workframe 0.1.12 → 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 (188) 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 +2 -2
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +1 -0
  8. package/scripts/verify-public-deploy.sh +65 -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/package.json +2 -2
  51. package/workframe-api/profile_api_lifecycle.py +66 -0
  52. package/workframe-api/profile_gateway.py +436 -0
  53. package/workframe-api/provider_bindings.py +673 -0
  54. package/workframe-api/provider_bootstrap.py +347 -0
  55. package/workframe-api/provider_catalog.py +180 -0
  56. package/workframe-api/rooms.py +1613 -0
  57. package/workframe-api/route_registry.py +331 -191
  58. package/workframe-api/run-typecheck.mjs +2 -1
  59. package/workframe-api/run_authority.py +287 -0
  60. package/workframe-api/run_ledger.py +470 -0
  61. package/workframe-api/run_surface_wiring.py +344 -0
  62. package/workframe-api/runtime_cohort.py +752 -0
  63. package/workframe-api/runtime_tokens.py +127 -0
  64. package/workframe-api/server.py +1453 -19513
  65. package/workframe-api/snapshot_feed.py +49 -0
  66. package/workframe-api/stack_config.py +33 -1
  67. package/workframe-api/supervisor_client.py +154 -0
  68. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  69. package/workframe-api/test_cell_authority.py +79 -0
  70. package/workframe-api/test_chat_bind.py +48 -0
  71. package/workframe-api/test_credential_lease_matrix.py +171 -0
  72. package/workframe-api/test_credential_resolve.py +51 -0
  73. package/workframe-api/test_credential_store.py +27 -0
  74. package/workframe-api/test_dogfood_flows.py +346 -0
  75. package/workframe-api/test_domain_entities.py +152 -0
  76. package/workframe-api/test_exception_hygiene.py +12 -0
  77. package/workframe-api/test_hermes_admin.py +72 -0
  78. package/workframe-api/test_install_wizard_resume.py +78 -0
  79. package/workframe-api/test_local_bootstrap.py +67 -0
  80. package/workframe-api/test_mention_helpers.py +35 -0
  81. package/workframe-api/test_mention_invoke.py +26 -0
  82. package/workframe-api/test_model_surface_consistency.py +1 -0
  83. package/workframe-api/test_oauth_pending.py +41 -0
  84. package/workframe-api/test_provider_bindings.py +52 -0
  85. package/workframe-api/test_provider_catalog.py +28 -0
  86. package/workframe-api/test_route_registry.py +171 -35
  87. package/workframe-api/test_run_authority.py +112 -0
  88. package/workframe-api/test_run_ledger.py +157 -0
  89. package/workframe-api/test_run_surface_wiring.py +130 -0
  90. package/workframe-api/test_runtime_cohort.py +85 -0
  91. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  92. package/workframe-api/test_server_reexports.py +99 -0
  93. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  94. package/workframe-api/turn_credentials.py +28 -13
  95. package/workframe-api/turn_overlay.py +715 -0
  96. package/workframe-api/updates.py +16 -1
  97. package/workframe-api/user_prefs.py +387 -0
  98. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  99. package/workframe-api/workspace_bootstrap.py +536 -0
  100. package/workframe-api/workspace_files.py +344 -0
  101. package/workframe-api/workspace_messaging.py +206 -0
  102. package/workframe-api/zk_auth.py +3 -2
  103. package/workframe-supervisor/server.py +10 -1
  104. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  105. package/workframe-ui/public/assets/{arc-B0OFRGmJ.js → arc-aUUffclm.js} +1 -1
  106. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  107. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  108. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  109. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  110. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  111. package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  120. package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  123. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  124. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  127. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  128. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  129. package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  132. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  133. package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  134. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  135. package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  136. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  137. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  138. package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  139. package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-mQWmB3z6.js} +1 -1
  140. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  141. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  142. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  143. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  144. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  145. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  146. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  147. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  148. package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  149. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  150. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  151. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  152. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  153. package/workframe-ui/public/assets/{line-CgQsmU35.js → line-BTCEHb7l.js} +1 -1
  154. package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-CkbydpnK.js} +1 -1
  155. package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  156. package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-yj_1x_qj.js} +3 -3
  157. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  158. package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  159. package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  160. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  161. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  162. package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  163. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  164. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  165. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  166. package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-D6mvlP96.js} +1 -1
  167. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  168. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  169. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  170. package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  171. package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  172. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  173. package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  174. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  175. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  176. package/workframe-ui/public/index.html +7 -5
  177. package/workframe-ui/public/workframe-build.json +5 -0
  178. package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
  179. package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
  180. package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
  181. package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
  182. package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
  183. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
  184. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
  185. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
  186. package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
  187. package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
  188. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +0 -1
@@ -0,0 +1,1139 @@
1
+ """WF-032 extract: model_surface."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import queue
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ import uuid
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from http.server import BaseHTTPRequestHandler
21
+
22
+ import openrouter_catalog
23
+ import profile_config_yaml
24
+ import user_prefs
25
+
26
+
27
+ def _srv():
28
+ import server as srv
29
+
30
+ return srv
31
+
32
+
33
+ def _resolve_models_profile(profile: str) -> str:
34
+ """Hermes profile slug for model picker reads. Use runtime u-* when it exists on disk."""
35
+ raw = str(profile or "").strip()
36
+ if not raw:
37
+ return _srv()._primary_profile()
38
+ slug = _srv().safe_profile_slug(raw)
39
+ if _srv()._is_runtime_profile_slug(slug) and _srv()._runtime_profile_on_disk(slug):
40
+ return slug
41
+ if _srv()._is_runtime_profile_slug(slug):
42
+ return _srv().resolve_validated_profile(_srv()._runtime_template_slug(slug))
43
+ return _srv().resolve_validated_profile(slug)
44
+
45
+
46
+ def _model_suggestion_row(
47
+ model_id: str,
48
+ *,
49
+ provider_label: str = "OpenRouter",
50
+ label: str = "",
51
+ description: str = "",
52
+ ) -> dict[str, str]:
53
+ slug = model_id.rsplit("/", 1)[-1] if "/" in model_id else model_id
54
+ return {
55
+ "provider": provider_label,
56
+ "model": model_id,
57
+ "label": label or slug,
58
+ "description": description or model_id,
59
+ }
60
+
61
+
62
+ def _augment_model_suggestions(
63
+ suggestions: list[dict[str, str]],
64
+ block: dict[str, Any],
65
+ ) -> list[dict[str, str]]:
66
+ """Ensure active primary and fallback chain models appear in the picker."""
67
+ seen = {str(row.get("model") or "") for row in suggestions}
68
+ extra: list[dict[str, str]] = []
69
+ primary = str(block.get("default") or "").strip()
70
+ if primary and primary not in seen:
71
+ bill = _billing_provider_id_from_hermes_config(str(block.get("provider") or "")) or str(
72
+ block.get("provider") or "openrouter"
73
+ )
74
+ extra.append(
75
+ _model_suggestion_row(
76
+ primary,
77
+ provider_label=_srv()._provider_display_label(bill),
78
+ label="Current primary",
79
+ description="Active model for this agent profile",
80
+ ),
81
+ )
82
+ seen.add(primary)
83
+ for entry in block.get("fallback_chain") or []:
84
+ if not isinstance(entry, dict):
85
+ continue
86
+ model = str(entry.get("model") or "").strip()
87
+ prov = str(entry.get("provider") or "openrouter").strip()
88
+ if not model:
89
+ continue
90
+ full = model if "/" in model else f"{prov}/{model}"
91
+ if full in seen:
92
+ continue
93
+ bill = _billing_provider_id_from_hermes_config(prov) or prov
94
+ extra.append(
95
+ _model_suggestion_row(
96
+ full,
97
+ provider_label=_srv()._provider_display_label(bill),
98
+ label=f"Fallback · {model}",
99
+ description="Configured in this profile's fallback chain",
100
+ ),
101
+ )
102
+ seen.add(full)
103
+ return extra + suggestions
104
+
105
+
106
+ _INTERNAL_LLM_PROXY_RE = re.compile(r"/internal/llm/([a-z0-9_-]+)/v1", re.I)
107
+
108
+
109
+ def _billing_provider_from_block(base_url: str, cfg_provider: str, hint: str) -> str:
110
+ """Pure resolver: the billing provider is the proxy URL segment (the stored fact).
111
+
112
+ Order: explicit hint > proxy URL segment > cfg_provider > "openrouter".
113
+ The model-id prefix (e.g. ``google/`` in ``google/gemini-2.5-flash``) is an
114
+ OpenRouter vendor namespace, NOT a billing provider, so it is never used here.
115
+ ponytail: removing model-prefix inference is the root-cause fix for the
116
+ "provider not recognized" bug where an OpenRouter-only user was billed to google.
117
+ """
118
+ h = str(hint or "").strip().lower()
119
+ if h and h not in {"custom", "auto"}:
120
+ return h
121
+ m = _INTERNAL_LLM_PROXY_RE.search(str(base_url or ""))
122
+ if m:
123
+ return m.group(1).lower()
124
+ cfg = str(cfg_provider or "").strip().lower()
125
+ if cfg and cfg not in {"custom", "auto"}:
126
+ return cfg
127
+ return "openrouter"
128
+
129
+
130
+ def _llm_billing_provider(
131
+ profile: str,
132
+ provider_hint: str = "",
133
+ *,
134
+ user_id: str = "",
135
+ workspace_id: str = "",
136
+ block: dict[str, Any] | None = None,
137
+ ) -> str:
138
+ """Map Hermes routing provider (often custom for proxy) to vault/billing provider id."""
139
+ prof = _srv().resolve_hermes_profile(profile)
140
+ block = block if block is not None else _read_model_block(prof)
141
+ cfg_provider = str(block.get("provider") or "").strip().lower()
142
+ billing_cfg = _billing_provider_id_from_hermes_config(cfg_provider)
143
+ user = str(user_id or "").strip()
144
+ ws = str(workspace_id or "").strip()
145
+ if billing_cfg and user and _srv()._user_can_use_llm(user, ws, billing_cfg):
146
+ return billing_cfg
147
+ candidate = _billing_provider_from_block(
148
+ str(block.get("base_url") or ""), cfg_provider, provider_hint,
149
+ )
150
+ if user and not _srv()._user_can_use_llm(user, ws, candidate):
151
+ connected = sorted(_srv()._user_llm_providers_for_picker(user))
152
+ if connected:
153
+ return connected[0]
154
+ return candidate or "openrouter"
155
+
156
+
157
+ def _profile_llm_proxy_matches_billing(profile: str, billing_provider: str) -> bool:
158
+ prof = _srv().resolve_hermes_profile(profile)
159
+ base = str(_read_model_block(prof).get("base_url") or "").strip()
160
+ return bool(base) and base == _srv()._llm_proxy_base_url(billing_provider)
161
+
162
+
163
+ def _profile_routing_matches_billing(
164
+ prof: str,
165
+ billing: str,
166
+ *,
167
+ block: dict[str, Any] | None = None,
168
+ ) -> bool:
169
+ """True when config.yaml already routes through the requested billing provider."""
170
+ billing = str(billing or "").strip().lower()
171
+ if not billing:
172
+ return False
173
+ block = block if block is not None else _read_model_block(prof)
174
+ model = str(block.get("default") or "").strip()
175
+ if not model:
176
+ return False
177
+ cfg = str(block.get("provider") or "").strip().lower()
178
+ if _srv()._oauth_llm_provider_spec(billing):
179
+ return cfg == _hermes_config_provider_id(billing).lower()
180
+ if cfg != "custom":
181
+ return False
182
+ return _srv()._profile_llm_proxy_matches_billing(prof, billing)
183
+
184
+ # Canonical 3-tier model chain. `primary` is what new sessions resolve to;
185
+ # the fallback list kicks in when the primary fails (rate limit, 5xx,
186
+ # timeout). Order matters — Hermes tries them in sequence. Per-profile
187
+ # overrides live in the profile's config.yaml under `model.default` and
188
+ # `fallback_providers`; this constant is the install-wide default the
189
+ # migration writes when a profile is missing either field.
190
+ HERMES_DEFAULT_PRIMARY = "google/gemini-2.5-flash"
191
+ HERMES_DEFAULT_FALLBACK_CHAIN: list[dict[str, str]] = [
192
+ {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.5"},
193
+ {"provider": "openrouter", "model": "meta-llama/llama-3.3-70b-instruct:free"},
194
+ ]
195
+
196
+ # ponytail: minimum-viable agentic models per LLM provider; user can escalate in settings.
197
+ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
198
+ "openrouter": {
199
+ "primary": "google/gemini-2.5-flash",
200
+ "fallbacks": list(HERMES_DEFAULT_FALLBACK_CHAIN),
201
+ },
202
+ "openai": {
203
+ "primary": "gpt-5.4-medium",
204
+ "fallbacks": [{"provider": "openai", "model": "gpt-4o-mini"}],
205
+ },
206
+ "anthropic": {
207
+ "primary": "claude-sonnet-4-5",
208
+ "fallbacks": [{"provider": "anthropic", "model": "claude-3-5-haiku-20241022"}],
209
+ },
210
+ "google": {
211
+ "primary": "gemini-2.5-flash",
212
+ "fallbacks": [{"provider": "google", "model": "gemini-2.5-flash-lite"}],
213
+ },
214
+ "deepseek": {
215
+ "primary": "deepseek-chat",
216
+ "fallbacks": [{"provider": "deepseek", "model": "deepseek-chat"}],
217
+ },
218
+ "codex": {
219
+ "primary": "gpt-5.4-medium",
220
+ "fallbacks": [
221
+ {"provider": "openai-codex", "model": "gpt-5.4-medium"},
222
+ {"provider": "openai-codex", "model": "gpt-5.4-mini"},
223
+ ],
224
+ },
225
+ "nous": {
226
+ "primary": "nousresearch/hermes-4-70b",
227
+ "fallbacks": [{"provider": "nous", "model": "nousresearch/hermes-4-70b"}],
228
+ },
229
+ }
230
+
231
+
232
+ # ponytail: billing id (connect catalog) ↔ Hermes config model.provider ↔ picker rows
233
+ _PROVIDER_CATALOG_TAGS: dict[str, frozenset[str]] = {
234
+ "codex": frozenset({"codex", "openai"}),
235
+ "openai": frozenset({"openai"}),
236
+ "openrouter": frozenset({"openrouter"}),
237
+ "anthropic": frozenset({"anthropic"}),
238
+ "google": frozenset({"google"}),
239
+ "deepseek": frozenset({"deepseek"}),
240
+ "nous": frozenset({"nous"}),
241
+ }
242
+
243
+ _CODEX_EXTRA_MODELS: tuple[tuple[str, str, str], ...] = (
244
+ ("gpt-5.4-medium", "GPT-5.4 Medium", "Codex default — agentic, tool-capable."),
245
+ ("gpt-5.4-mini", "GPT-5.4 Mini", "Faster Codex model on ChatGPT account."),
246
+ )
247
+
248
+
249
+ def _billing_provider_id_from_hermes_config(cfg_provider: str) -> str:
250
+ p = str(cfg_provider or "").strip().lower()
251
+ if p in {"openai-codex", "openai_codex", "codex"}:
252
+ return "codex"
253
+ if p in PROVIDER_MVP_MODELS:
254
+ return p
255
+ return ""
256
+
257
+
258
+ def _catalog_tags_for_billing_provider(provider_id: str) -> frozenset[str]:
259
+ return _PROVIDER_CATALOG_TAGS.get(str(provider_id or "").strip().lower(), frozenset())
260
+
261
+
262
+ def _catalog_row_for_billing_provider(row: dict[str, str], provider_id: str) -> bool:
263
+ tags = _catalog_tags_for_billing_provider(provider_id)
264
+ if not tags:
265
+ return False
266
+ row_tag = str(row.get("provider") or "").strip().lower()
267
+ return row_tag in tags
268
+
269
+
270
+ def _provider_display_label(provider_id: str) -> str:
271
+ spec = _srv()._catalog_provider(provider_id) or {}
272
+ return str(spec.get("label") or provider_id).strip() or provider_id
273
+
274
+
275
+ def _model_catalog_rows_for_provider(provider_id: str) -> list[dict[str, str]]:
276
+ """Curated models a connected billing provider can run."""
277
+ provider_key = str(provider_id or "").strip().lower()
278
+ label = _srv()._provider_display_label(provider_key)
279
+ rows: list[dict[str, str]] = []
280
+ seen: set[str] = set()
281
+
282
+ def _add(model: str, row_label: str, description: str) -> None:
283
+ mid = str(model or "").strip()
284
+ if not mid or mid in seen:
285
+ return
286
+ seen.add(mid)
287
+ rows.append(
288
+ {
289
+ "provider": provider_key,
290
+ "billing_provider": provider_key,
291
+ "model": mid,
292
+ "label": row_label,
293
+ "description": description,
294
+ }
295
+ )
296
+
297
+ if provider_key == "codex":
298
+ for model, row_label, description in _CODEX_EXTRA_MODELS:
299
+ _add(model, row_label, description)
300
+ mvp = PROVIDER_MVP_MODELS.get(provider_key) or {}
301
+ primary = str(mvp.get("primary") or "").strip()
302
+ if primary:
303
+ _add(primary, primary, f"Default for {label}.")
304
+ for fb in mvp.get("fallbacks") or []:
305
+ if isinstance(fb, dict):
306
+ _add(str(fb.get("model") or ""), str(fb.get("model") or ""), f"Fallback for {label}.")
307
+ for row in HERMES_MODEL_CATALOG:
308
+ if _catalog_row_for_billing_provider(row, provider_key):
309
+ _add(
310
+ str(row.get("model") or ""),
311
+ str(row.get("label") or row.get("model") or ""),
312
+ str(row.get("description") or ""),
313
+ )
314
+ return rows
315
+
316
+
317
+ def _suggestions_for_connected_llm_providers(connected: set[str]) -> list[dict[str, str]]:
318
+ out: list[dict[str, str]] = []
319
+ seen: set[str] = set()
320
+ for provider_id in sorted(connected):
321
+ for row in _model_catalog_rows_for_provider(provider_id):
322
+ mid = str(row.get("model") or "").strip()
323
+ if not mid or mid in seen:
324
+ continue
325
+ seen.add(mid)
326
+ out.append(row)
327
+ return out
328
+
329
+
330
+ def _model_id_vendor_and_bare(model_id: str) -> tuple[str, str]:
331
+ """Split vendor/model ids (e.g. openai-codex/gpt-5.4-mini) into billing + bare model."""
332
+ mid = str(model_id or "").strip()
333
+ if "/" not in mid:
334
+ return "", mid
335
+ prefix, bare = mid.split("/", 1)
336
+ billing = _billing_provider_id_from_hermes_config(prefix)
337
+ return billing, bare.strip()
338
+
339
+
340
+ def _persisted_model_id(model_id: str, billing: str) -> str:
341
+ billing = str(billing or "").strip().lower()
342
+ _, bare = _model_id_vendor_and_bare(model_id)
343
+ if _srv()._oauth_llm_provider_spec(billing) and bare:
344
+ return bare
345
+ return str(model_id or "").strip()
346
+
347
+
348
+ def _apply_model_persisted(
349
+ profile: str,
350
+ billing: str,
351
+ model_id: str,
352
+ user_id: str = "",
353
+ *,
354
+ restart_gateway: bool = True,
355
+ ) -> tuple[bool, str]:
356
+ """Write model surface and verify default landed on disk before returning ok."""
357
+ prof = _srv().resolve_hermes_profile(profile)
358
+ billing = str(billing or "").strip().lower()
359
+ if billing:
360
+ if not _apply_model_for_billing_provider(prof, billing, model_id, user_id=user_id):
361
+ return False, "model apply failed"
362
+ else:
363
+ ok, err = _set_profile_model(prof, model_id)
364
+ if not ok:
365
+ return False, err or "model apply failed"
366
+ expected = _persisted_model_id(model_id, billing)
367
+ got = str(_parse_model_block_from_disk(prof).get("default") or "").strip()
368
+ if expected and got != expected:
369
+ return False, f"model not persisted (expected {expected}, got {got or 'empty'})"
370
+ if restart_gateway and _srv()._is_runtime_profile_slug(prof):
371
+ _srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
372
+ return True, ""
373
+
374
+
375
+ def _mirror_template_model_to_runtime(
376
+ user_id: str,
377
+ template_slug: str,
378
+ billing: str,
379
+ model_id: str,
380
+ ) -> tuple[bool, str]:
381
+ """After a template model save, apply the same pick to the user's runtime profile."""
382
+ user = str(user_id or "").strip()
383
+ template = _srv().safe_profile_slug(template_slug)
384
+ if not user or _srv()._is_runtime_profile_slug(template):
385
+ return True, ""
386
+ runtime = _srv()._runtime_profile_slug(user, template)
387
+ if not _srv()._runtime_profile_on_disk(runtime):
388
+ return True, ""
389
+ return _apply_model_persisted(
390
+ runtime, billing, model_id, user_id=user, restart_gateway=False,
391
+ )
392
+
393
+
394
+ def _resolve_billing_provider_for_model(
395
+ model_id: str,
396
+ connected: set[str],
397
+ *,
398
+ prefer: str = "",
399
+ ) -> str:
400
+ mid = str(model_id or "").strip()
401
+ if not mid or not connected:
402
+ return ""
403
+ vendor, bare = _model_id_vendor_and_bare(mid)
404
+ if vendor and vendor in connected:
405
+ return vendor
406
+ match_ids = [mid]
407
+ if bare and bare not in match_ids:
408
+ match_ids.append(bare)
409
+ pref = str(prefer or "").strip().lower()
410
+ if pref and pref in connected:
411
+ for row in _model_catalog_rows_for_provider(pref):
412
+ row_model = str(row.get("model") or "").strip()
413
+ if row_model in match_ids:
414
+ return pref
415
+ for provider_id in sorted(connected):
416
+ for row in _model_catalog_rows_for_provider(provider_id):
417
+ row_model = str(row.get("model") or "").strip()
418
+ if row_model in match_ids:
419
+ return provider_id
420
+ low = (bare or mid).lower()
421
+ if low.startswith("gpt-") and "codex" in connected:
422
+ return "codex"
423
+ if low.startswith("gpt-") and "openai" in connected:
424
+ return "openai"
425
+ if "/" in mid and "openrouter" in connected:
426
+ prefix = mid.split("/", 1)[0].strip().lower()
427
+ if not _billing_provider_id_from_hermes_config(prefix):
428
+ return "openrouter"
429
+ if low.startswith("claude") and "anthropic" in connected:
430
+ return "anthropic"
431
+ if "gemini" in low and "google" in connected:
432
+ return "google"
433
+ if "deepseek" in low and "deepseek" in connected:
434
+ return "deepseek"
435
+ return ""
436
+
437
+
438
+ def _strip_profile_model_proxy_fields(profile: str) -> None:
439
+ """OAuth/native providers: drop internal proxy base_url + lease api_key from model block."""
440
+ config_path = _srv()._profile_gateway_config_path(profile)
441
+ if not config_path or not config_path.is_file():
442
+ return
443
+ try:
444
+ lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
445
+ except OSError:
446
+ return
447
+ out: list[str] = []
448
+ in_model = False
449
+ for line in lines:
450
+ stripped = line.lstrip()
451
+ if stripped.startswith("model:") and not line.startswith((" ", "\t")):
452
+ in_model = True
453
+ out.append(line)
454
+ continue
455
+ if in_model and stripped and not line.startswith((" ", "\t", "#")):
456
+ in_model = False
457
+ if in_model and stripped.startswith(("base_url:", "api_key:")):
458
+ continue
459
+ out.append(line)
460
+ try:
461
+ config_path.write_text("".join(out), encoding="utf-8")
462
+ except OSError:
463
+ return
464
+
465
+
466
+ def _apply_model_for_billing_provider(
467
+ profile: str,
468
+ billing: str,
469
+ model_id: str,
470
+ user_id: str = "",
471
+ ) -> bool:
472
+ billing = str(billing or "").strip().lower()
473
+ _, bare = _model_id_vendor_and_bare(model_id)
474
+ if _srv()._oauth_llm_provider_spec(billing) and bare:
475
+ model_id = bare
476
+ ok, _ = _set_profile_model(profile, model_id)
477
+ if not ok:
478
+ return False
479
+ if _srv()._oauth_llm_provider_spec(billing):
480
+ ok_provider, _ = _set_profile_model_provider(profile, _hermes_config_provider_id(billing))
481
+ if not ok_provider:
482
+ return False
483
+ _strip_profile_model_proxy_fields(profile)
484
+ fallbacks = PROVIDER_MVP_MODELS.get(billing, {}).get("fallbacks") or []
485
+ if isinstance(fallbacks, list):
486
+ chain = _read_model_block(profile).get("fallback_chain") or []
487
+ if not chain:
488
+ _write_fallback_chain(profile, fallbacks)
489
+ user = str(user_id or "").strip()
490
+ if user:
491
+ _srv()._sync_oauth_llm_to_profile(profile, user, billing)
492
+ return True
493
+ ok_provider, _ = _set_profile_model_provider(profile, "custom")
494
+ if not ok_provider:
495
+ return False
496
+ _set_profile_model_base_url(profile, _srv()._llm_proxy_base_url(billing))
497
+ return True
498
+
499
+
500
+ def _hermes_config_provider_id(provider_key: str) -> str:
501
+ spec = _srv()._catalog_provider(provider_key)
502
+ if spec and spec.get("hermes_auth_id"):
503
+ return str(spec["hermes_auth_id"])
504
+ return str(provider_key or "").strip().lower()
505
+
506
+
507
+ def _first_connected_llm_provider(user_id: str = "", workspace_id: str = "") -> str:
508
+ for spec in _srv().PROVIDER_CONNECT_CATALOG:
509
+ if str(spec.get("category") or "") != "llm":
510
+ continue
511
+ provider_id = str(spec["id"])
512
+ if user_id and _user_provider_connected(str(user_id), spec):
513
+ return provider_id
514
+ if workspace_id and str(spec.get("env_var") or ""):
515
+ resolved = _srv()._resolve_credential("", str(workspace_id), provider_id)
516
+ if resolved and _srv()._credential_secret(resolved, str(user_id or "")):
517
+ return provider_id
518
+ return "openrouter"
519
+
520
+
521
+ def _apply_mvp_model_for_provider(profile: str, provider: str) -> bool:
522
+ """Write model.default, model.provider, and fallback_providers to config.yaml."""
523
+ provider_key = str(provider or "openrouter").strip().lower()
524
+ spec = PROVIDER_MVP_MODELS.get(provider_key) or PROVIDER_MVP_MODELS["openrouter"]
525
+ try:
526
+ _srv()._ensure_gateway_config_file(profile)
527
+ except ValueError:
528
+ return False
529
+ ok, _ = _set_profile_model(profile, str(spec["primary"]))
530
+ if not ok:
531
+ return False
532
+ ok_provider, _ = _set_profile_model_provider(profile, _hermes_config_provider_id(provider_key))
533
+ fallbacks = spec.get("fallbacks") if isinstance(spec.get("fallbacks"), list) else []
534
+ ok_chain, _ = _write_fallback_chain(profile, fallbacks)
535
+ return ok_provider and ok_chain
536
+
537
+
538
+ def _bootstrap_model_after_llm_connect(
539
+ user_id: str,
540
+ workspace_id: str,
541
+ provider: str,
542
+ ) -> None:
543
+ """Seed MVP model on runtime + primary profiles when an LLM key is connected."""
544
+ spec = _srv()._catalog_provider(provider)
545
+ if not spec or str(spec.get("category") or "") != "llm":
546
+ return
547
+ provider_key = str(provider or "").strip().lower()
548
+ user = str(user_id or "").strip()
549
+ ws = str(workspace_id or "").strip()
550
+ primary = _srv()._primary_profile()
551
+ if primary:
552
+ if user and _srv()._oauth_llm_provider_spec(provider_key):
553
+ _srv()._reconcile_profile_llm_for_user(primary, user, ws, prefer_provider=provider_key)
554
+ else:
555
+ _, model = _srv()._read_model_from_config(primary)
556
+ if not str(model or "").strip():
557
+ _apply_mvp_model_for_provider(primary, provider_key)
558
+ elif user:
559
+ _srv()._reconcile_profile_llm_for_user(primary, user, ws, prefer_provider=provider_key)
560
+ template = primary or "workframe-agent"
561
+ runtime = _srv()._runtime_profile_slug(user_id, template)
562
+ if _srv()._runtime_profile_on_disk(runtime):
563
+ if user and _srv()._oauth_llm_provider_spec(provider_key):
564
+ _srv()._reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
565
+ else:
566
+ _, model = _srv()._read_model_from_config(runtime)
567
+ if not str(model or "").strip():
568
+ _apply_mvp_model_for_provider(runtime, provider_key)
569
+ elif user:
570
+ _srv()._reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
571
+
572
+
573
+ # Curated snapshot of commonly-used models per provider. Surfaced in the
574
+ # Workframe picker as suggestions — the BFF also accepts arbitrary
575
+ # `provider/model` ids so users aren't boxed in. The picker shows this
576
+ # list ONLY for the active provider; cross-provider entries are hidden
577
+ # because they need the matching API key in env to actually work.
578
+ HERMES_MODEL_CATALOG: list[dict[str, str]] = [
579
+ {"provider": "Anthropic", "model": "claude-sonnet-4-5",
580
+ "label": "Claude Sonnet 4.5",
581
+ "description": "Latest Sonnet. Strong general purpose."},
582
+ {"provider": "Anthropic", "model": "claude-opus-4-1",
583
+ "label": "Claude Opus 4.1",
584
+ "description": "Heaviest reasoning. Slow but thorough."},
585
+ {"provider": "Anthropic", "model": "claude-3-5-sonnet-20241022",
586
+ "label": "Claude 3.5 Sonnet",
587
+ "description": "Previous gen. Still very capable."},
588
+ {"provider": "Anthropic", "model": "claude-3-5-haiku-20241022",
589
+ "label": "Claude 3.5 Haiku",
590
+ "description": "Fast and cheap."},
591
+ {"provider": "OpenAI", "model": "gpt-5.4-medium",
592
+ "label": "GPT-5.4 Medium", "description": "Workframe OpenAI default — agentic, tool-capable."},
593
+ {"provider": "OpenAI", "model": "gpt-4o",
594
+ "label": "GPT-4o", "description": "OpenAI flagship multimodal."},
595
+ {"provider": "OpenAI", "model": "gpt-4o-mini",
596
+ "label": "GPT-4o mini", "description": "Cheap, fast."},
597
+ {"provider": "OpenAI", "model": "o1",
598
+ "label": "o1", "description": "Reasoning model."},
599
+ {"provider": "OpenAI", "model": "o1-mini",
600
+ "label": "o1 mini", "description": "Cheaper reasoning."},
601
+ {"provider": "OpenAI", "model": "gpt-5.4-mini",
602
+ "label": "GPT-5.4 Mini", "description": "Faster Codex / OpenAI model."},
603
+ {"provider": "Codex", "model": "gpt-5.4-medium",
604
+ "label": "GPT-5.4 Medium (Codex)", "description": "Codex ChatGPT account default."},
605
+ {"provider": "Codex", "model": "gpt-5.4-mini",
606
+ "label": "GPT-5.4 Mini (Codex)", "description": "Faster Codex model."},
607
+ {"provider": "Google", "model": "gemini-2.5-pro",
608
+ "label": "Gemini 2.5 Pro",
609
+ "description": "Long context, strong reasoning."},
610
+ {"provider": "Google", "model": "gemini-2.0-flash",
611
+ "label": "Gemini 2.0 Flash", "description": "Fast, cheap."},
612
+ {"provider": "DeepSeek", "model": "deepseek-chat",
613
+ "label": "DeepSeek Chat",
614
+ "description": "DeepSeek general chat model."},
615
+ {"provider": "DeepSeek", "model": "deepseek-reasoner",
616
+ "label": "DeepSeek Reasoner",
617
+ "description": "Reasoning-focused DeepSeek model."},
618
+ {"provider": "OpenRouter", "model": "openrouter/auto",
619
+ "label": "OpenRouter Auto",
620
+ "description": "OpenRouter picks the best model per prompt."},
621
+ {"provider": "OpenRouter", "model": "google/gemini-2.5-flash",
622
+ "label": "Gemini 2.5 Flash",
623
+ "description": "Fast, capable default for OpenRouter BYOK."},
624
+ {"provider": "OpenRouter", "model": "anthropic/claude-sonnet-4.5",
625
+ "label": "Claude Sonnet 4.5 (via OR)",
626
+ "description": "Strong agentic fallback on OpenRouter."},
627
+ {"provider": "OpenRouter", "model": "meta-llama/llama-3.3-70b-instruct:free",
628
+ "label": "Llama 3.3 70B (free)",
629
+ "description": "Free-tier OpenRouter fallback when available."},
630
+ {"provider": "OpenRouter", "model": "z-ai/glm-5.1",
631
+ "label": "GLM 5.1",
632
+ "description": "High-availability model on OpenRouter."},
633
+ {"provider": "OpenRouter", "model": "nousresearch/hermes-4-70b",
634
+ "label": "Hermes 4 70B",
635
+ "description": "Agent-tuned model on OpenRouter."},
636
+ {"provider": "OpenRouter", "model": "openrouter/nvidia/llama-3.1-nemotron-70b-instruct",
637
+ "label": "Nemotron 70B Instruct",
638
+ "description": "NVIDIA Nemotron via OpenRouter."},
639
+ {"provider": "OpenRouter", "model": "openrouter/openai/gpt-4o",
640
+ "label": "GPT-4o (via OR)",
641
+ "description": "GPT-4o through OpenRouter."},
642
+ ]
643
+
644
+
645
+ def _set_profile_model(profile: str, model_id: str) -> tuple[bool, str]:
646
+ """Update `model.default` in config.yaml via profile_config_yaml."""
647
+ _srv()._normalize_profile_config_yaml(profile)
648
+ try:
649
+ config_path = _srv()._ensure_gateway_config_file(profile)
650
+ except ValueError as exc:
651
+ return False, str(exc)
652
+ try:
653
+ profile_config_yaml.update_model_surface(config_path, default=model_id)
654
+ except OSError as exc:
655
+ return False, f"write failed: {exc}"
656
+ return True, ""
657
+
658
+
659
+ def _set_profile_model_provider(profile: str, provider: str) -> tuple[bool, str]:
660
+ _srv()._normalize_profile_config_yaml(profile)
661
+ try:
662
+ config_path = _srv()._ensure_gateway_config_file(profile)
663
+ except ValueError as exc:
664
+ return False, str(exc)
665
+ provider = str(provider or "").strip().lower()
666
+ if not provider:
667
+ return False, "provider required"
668
+ try:
669
+ profile_config_yaml.update_model_surface(config_path, provider=provider)
670
+ except OSError as exc:
671
+ return False, f"write failed: {exc}"
672
+ return True, ""
673
+
674
+
675
+ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
676
+ """Parse the model surface from the profile's config.yaml:
677
+ `model.default`, `model.provider`, etc. (siblings of `model:`) AND
678
+ the top-level `fallback_providers:` block. Pure text scan — no
679
+ pyyaml in the image.
680
+ """
681
+ config_path = _srv()._profile_config_path(profile)
682
+ empty: dict[str, Any] = {
683
+ "default": "",
684
+ "provider": "",
685
+ "base_url": "",
686
+ "api_mode": "",
687
+ "api_key": "",
688
+ "providers": {},
689
+ "fallback_chain": [],
690
+ }
691
+ if not config_path:
692
+ return empty
693
+ try:
694
+ lines = config_path.read_text(encoding="utf-8").splitlines()
695
+ except OSError:
696
+ return empty
697
+
698
+ out = dict(empty)
699
+ in_model = False
700
+ in_fallback = False
701
+ fallback_indent = -1
702
+ saw_top_model = False
703
+ for raw in lines:
704
+ stripped = raw.rstrip()
705
+ if not stripped.strip() or stripped.lstrip().startswith("#"):
706
+ continue
707
+ indent = len(stripped) - len(stripped.lstrip())
708
+ content = stripped.lstrip()
709
+
710
+ # Track which top-level block we're in. The model block is the
711
+ # `model:` section (indent 0 + `model:`); fallback_providers is
712
+ # a SIBLING at indent 0, not nested under model.
713
+ if indent == 0 and content.startswith("model:"):
714
+ if saw_top_model:
715
+ in_model = False
716
+ continue
717
+ saw_top_model = True
718
+ in_model = True
719
+ in_fallback = False
720
+ continue
721
+ if indent == 0:
722
+ in_model = False
723
+
724
+ # `model:` block: read its immediate sub-keys (default, provider,
725
+ # base_url, api_mode). They sit at indent 2 with a `:`.
726
+ if in_model and indent == 2 and ":" in content and not in_fallback:
727
+ key, _, value = content.partition(":")
728
+ key = key.strip()
729
+ value = value.strip().strip('"').strip("'")
730
+ if key == "default":
731
+ out["default"] = value
732
+ elif key == "provider":
733
+ out["provider"] = value
734
+ elif key == "base_url":
735
+ out["base_url"] = value
736
+ elif key == "api_mode":
737
+ out["api_mode"] = value
738
+ elif key == "api_key":
739
+ out["api_key"] = value
740
+ continue
741
+
742
+ # `fallback_providers:` is a top-level list. The key is at
743
+ # indent 0; list items at indent 2 with `- `; sub-keys at
744
+ # indent 4 (e.g. `model:` under the entry). An inline form like
745
+ # `fallback_providers: []` or `{}` is empty; a bare
746
+ # `fallback_providers:` (nothing after the colon) starts a
747
+ # multi-line list and must NOT reset in_fallback.
748
+ if indent == 0 and content.startswith("fallback_providers:"):
749
+ in_fallback = True
750
+ fallback_indent = indent
751
+ after = content.split(":", 1)[1].strip()
752
+ if after and (
753
+ after in {"{}", "[]"}
754
+ or (after.startswith("{") and after.endswith("}"))
755
+ or (after.startswith("[") and after.endswith("]"))
756
+ ):
757
+ in_fallback = False
758
+ continue
759
+
760
+ if in_fallback and indent == fallback_indent + 2 and ":" in content and not content.startswith("- "):
761
+ # ponytail: wizard once wrote flat `model:` keys (no list) — tolerate one entry per line
762
+ k, _, v = content.partition(":")
763
+ key = k.strip()
764
+ val = v.strip().strip('"').strip("'")
765
+ if key == "model" and val:
766
+ out["fallback_chain"].append({"model": val})
767
+ continue
768
+
769
+ if in_fallback and indent == fallback_indent + 2 and content.startswith("- "):
770
+ # Start of a new fallback entry. First key may be inline.
771
+ inline = content[2:].strip()
772
+ entry: dict[str, str] = {}
773
+ if ":" in inline:
774
+ k, _, v = inline.partition(":")
775
+ entry[k.strip()] = v.strip().strip('"').strip("'")
776
+ out["fallback_chain"].append(entry)
777
+ continue
778
+
779
+ if in_fallback and indent > fallback_indent + 2 and ":" in content:
780
+ k, _, v = content.partition(":")
781
+ key = k.strip()
782
+ val = v.strip().strip('"').strip("'")
783
+ if out["fallback_chain"] and key in {"provider", "model", "base_url"}:
784
+ out["fallback_chain"][-1][key] = val
785
+ continue
786
+
787
+ if in_fallback and indent <= fallback_indent:
788
+ # Exited the fallback block.
789
+ in_fallback = False
790
+ return out
791
+
792
+
793
+ def _read_model_block(profile: str) -> dict[str, Any]:
794
+ """Model surface for a profile. Runtime u-* slugs inherit default/chain from template."""
795
+ raw = str(profile or "").strip()
796
+ if not raw:
797
+ return _parse_model_block_from_disk("")
798
+ try:
799
+ prof = _srv().resolve_hermes_profile(raw)
800
+ except ValueError:
801
+ return _parse_model_block_from_disk(raw)
802
+ block = _parse_model_block_from_disk(prof)
803
+ if not _srv()._is_runtime_profile_slug(prof):
804
+ return block
805
+ # ponytail: runtime with model+provider on disk — skip template read on bind/chat hot path.
806
+ if str(block.get("default") or "").strip() and str(block.get("provider") or "").strip():
807
+ return block
808
+ template = _srv().resolve_validated_profile(_srv()._runtime_template_slug(prof))
809
+ tblock = _parse_model_block_from_disk(template)
810
+ if not str(block.get("default") or "").strip() and str(tblock.get("default") or "").strip():
811
+ block["default"] = tblock["default"]
812
+ if not block.get("fallback_chain") and tblock.get("fallback_chain"):
813
+ block["fallback_chain"] = list(tblock["fallback_chain"])
814
+ tpl_provider = str(tblock.get("provider") or "").strip()
815
+ if not str(block.get("provider") or "").strip() and tpl_provider:
816
+ block["provider"] = tpl_provider
817
+ return block
818
+
819
+
820
+ def _sync_runtime_model_from_template(runtime: str, template: str) -> None:
821
+ """Keep per-user runtime Hermes config aligned with agent template model picks."""
822
+ runtime_slug = _srv().safe_profile_slug(runtime)
823
+ template_slug = _srv().resolve_validated_profile(template)
824
+ if not _srv()._is_runtime_profile_slug(runtime_slug) or not _srv().profile_exists(runtime_slug):
825
+ return
826
+ tblock = _parse_model_block_from_disk(template_slug)
827
+ default = str(tblock.get("default") or "").strip()
828
+ if not default:
829
+ return
830
+ connected: set[str] = set()
831
+ billing = _billing_provider_id_from_hermes_config(str(tblock.get("provider") or ""))
832
+ if not billing:
833
+ billing = _resolve_billing_provider_for_model(default, connected) or ""
834
+ _, bare = _model_id_vendor_and_bare(default)
835
+ model_id = bare or default
836
+ if billing:
837
+ _apply_model_for_billing_provider(runtime_slug, billing, model_id)
838
+ return
839
+ _set_profile_model(runtime_slug, model_id)
840
+ chain = [e for e in (tblock.get("fallback_chain") or []) if isinstance(e, dict)]
841
+ if chain:
842
+ _write_fallback_chain(runtime_slug, chain)
843
+
844
+
845
+ def _write_fallback_chain(profile: str, chain: list[dict[str, str]]) -> tuple[bool, str]:
846
+ """Rewrite fallback_providers via profile_config_yaml (WF-033)."""
847
+ _srv()._normalize_profile_config_yaml(profile)
848
+ config_path = _srv()._profile_gateway_config_path(profile)
849
+ if not config_path or not config_path.is_file():
850
+ try:
851
+ config_path = _srv()._ensure_gateway_config_file(profile)
852
+ except ValueError as exc:
853
+ return False, str(exc)
854
+ normalized: list[dict[str, str]] = []
855
+ for entry in chain:
856
+ if not isinstance(entry, dict):
857
+ continue
858
+ provider = str(entry.get("provider", "")).strip()
859
+ model = str(entry.get("model", "")).strip()
860
+ if provider and model:
861
+ normalized.append({"provider": provider, "model": model})
862
+ try:
863
+ profile_config_yaml.update_model_surface(config_path, fallback_chain=normalized)
864
+ except OSError as exc:
865
+ return False, f"write failed: {exc}"
866
+ _srv()._normalize_profile_config_yaml(profile)
867
+ return True, ""
868
+
869
+
870
+ def hermes_models(
871
+ profile: str = "",
872
+ user_id: str = "",
873
+ workspace_id: str = "",
874
+ *,
875
+ selection_only: bool = False,
876
+ ) -> dict[str, Any]:
877
+ """Return a profile's model surface: primary, fallback chain,
878
+ and curated suggestions scoped to the active provider. The catalog
879
+ is a hint list, not a hardcoded menu — the BFF accepts any
880
+ `provider/model` id and the picker always lets the user type a
881
+ custom one.
882
+ """
883
+ picker_llm = _srv()._user_llm_providers_for_picker(user_id) if user_id else set()
884
+ connected = _srv()._connected_provider_names(user_id, workspace_id)
885
+ has_llm = _srv()._user_llm_has_provider(user_id, workspace_id) if user_id else any(
886
+ str(spec.get("category") or "") == "llm"
887
+ and str(spec.get("id")).lower() in {n.lower() for n in connected}
888
+ for spec in _srv().PROVIDER_CONNECT_CATALOG
889
+ )
890
+ connected_llm = picker_llm if user_id else {
891
+ str(spec["id"]).lower()
892
+ for spec in _srv().PROVIDER_CONNECT_CATALOG
893
+ if str(spec.get("category") or "") == "llm"
894
+ and str(spec.get("id")).lower() in {n.lower() for n in connected}
895
+ }
896
+
897
+ if selection_only:
898
+ user_primary, user_chain = user_prefs.read_user_llm_prefs(user_id) if user_id else ("", [])
899
+ primary = user_primary or (HERMES_DEFAULT_PRIMARY if has_llm else "")
900
+ billing = _resolve_billing_provider_for_model(primary, connected_llm) if primary else ""
901
+ suggestions = _augment_model_suggestions(
902
+ _suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
903
+ {
904
+ "default": primary,
905
+ "provider": billing or "",
906
+ "base_url": "",
907
+ "providers": {},
908
+ "fallback_chain": user_chain,
909
+ },
910
+ )
911
+ return {
912
+ "ok": True,
913
+ "profile": "",
914
+ "primary": primary if has_llm else "",
915
+ "provider": billing if has_llm else "",
916
+ "base_url": "",
917
+ "fallback_chain": user_chain if has_llm else [],
918
+ "suggestions": suggestions,
919
+ "connected_providers": sorted(picker_llm if user_id else connected),
920
+ "has_llm_provider": has_llm,
921
+ "billing_provider": billing if has_llm else "",
922
+ "selection_only": True,
923
+ "default_primary": HERMES_DEFAULT_PRIMARY,
924
+ "default_fallback_chain": HERMES_DEFAULT_FALLBACK_CHAIN,
925
+ }
926
+
927
+ try:
928
+ primary_profile = _resolve_models_profile(profile) if profile else _srv()._primary_profile()
929
+ except ValueError as exc:
930
+ return {"ok": False, "error": str(exc)}
931
+ if primary_profile:
932
+ _srv()._ensure_profile_auth_pool(primary_profile, user_id, workspace_id)
933
+ block = _read_model_block(primary_profile) if primary_profile else {
934
+ "default": "", "provider": "", "base_url": "",
935
+ "providers": {}, "fallback_chain": [],
936
+ }
937
+ active_provider = block.get("provider", "").strip()
938
+ suggestions = _augment_model_suggestions(
939
+ _suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
940
+ block,
941
+ )
942
+ primary_model = str(block.get("default") or "").strip()
943
+ billing = _billing_provider_id_from_hermes_config(active_provider)
944
+ if not billing and primary_model:
945
+ billing = _resolve_billing_provider_for_model(primary_model, connected_llm) or ""
946
+ _, bare_primary = _model_id_vendor_and_bare(primary_model)
947
+ if _srv()._oauth_llm_provider_spec(billing) and bare_primary:
948
+ primary_model = bare_primary
949
+ # ponytail: skip OpenRouter live catalog on Codex/other billing — blocks stdlib server up to 45s
950
+ if user_id and "openrouter" in connected_llm and billing in ("", "openrouter"):
951
+ resolved = _srv()._resolve_credential(user_id, workspace_id, "openrouter", user_only=True)
952
+ secret = _srv()._credential_secret(resolved or {}, user_id) if resolved else ""
953
+ live = openrouter_catalog.live_suggestions(secret, limit=30, timeout=8)
954
+ if live:
955
+ seen = {str(r.get("model") or "") for r in suggestions}
956
+ suggestions = [
957
+ {**row, "label": f"Live · {row.get('label', row.get('model', ''))}"}
958
+ for row in live
959
+ if str(row.get("model") or "") not in seen
960
+ ] + suggestions
961
+ seen_models: set[str] = set()
962
+ deduped: list[dict[str, str]] = []
963
+ for row in suggestions:
964
+ mid = str(row.get("model") or "").strip()
965
+ if not mid or mid in seen_models:
966
+ continue
967
+ seen_models.add(mid)
968
+ deduped.append(row)
969
+ suggestions = deduped
970
+ if not billing and user_id and primary_profile:
971
+ billing = _llm_billing_provider(
972
+ primary_profile, user_id=user_id, workspace_id=workspace_id,
973
+ )
974
+ display_provider = billing
975
+ return {
976
+ "ok": True,
977
+ "profile": primary_profile,
978
+ "primary": primary_model if has_llm else "",
979
+ "provider": display_provider if has_llm else "",
980
+ "base_url": block.get("base_url", ""),
981
+ "fallback_chain": block.get("fallback_chain", []) if has_llm else [],
982
+ "suggestions": suggestions,
983
+ "connected_providers": sorted(picker_llm if user_id else connected),
984
+ "has_llm_provider": has_llm,
985
+ "billing_provider": billing if has_llm else "",
986
+ "default_primary": HERMES_DEFAULT_PRIMARY,
987
+ "default_fallback_chain": HERMES_DEFAULT_FALLBACK_CHAIN,
988
+ }
989
+
990
+
991
+ def hermes_apply_default_model_config() -> dict[str, Any]:
992
+ """One-shot migration: every profile gets owl-alpha as the primary
993
+ and the canonical 3-tier fallback chain. Idempotent — running it
994
+ again produces the same state. Use after a fresh install or to
995
+ recover from a profile whose config was hand-edited into a bad
996
+ state.
997
+ """
998
+ profiles = _list_profiles()
999
+ results: list[dict[str, str]] = []
1000
+ for profile in profiles:
1001
+ ok_model, err_model = _set_profile_model(profile, HERMES_DEFAULT_PRIMARY)
1002
+ ok_chain, err_chain = _write_fallback_chain(profile, HERMES_DEFAULT_FALLBACK_CHAIN)
1003
+ results.append({
1004
+ "profile": profile,
1005
+ "ok": "yes" if (ok_model and ok_chain) else "no",
1006
+ "error": err_model or err_chain or "",
1007
+ })
1008
+ return {
1009
+ "ok": True,
1010
+ "applied_to": [r["profile"] for r in results if r["ok"] == "yes"],
1011
+ "details": results,
1012
+ }
1013
+
1014
+
1015
+ def hermes_model_set(
1016
+ profile: str,
1017
+ model_id: str,
1018
+ user_id: str = "",
1019
+ workspace_id: str = "",
1020
+ *,
1021
+ selection_only: bool = False,
1022
+ billing_provider: str = "",
1023
+ ) -> dict[str, Any]:
1024
+ """Set primary model and align Hermes provider routing to a connected billing provider."""
1025
+ user = str(user_id or "").strip()
1026
+ if selection_only:
1027
+ if not user:
1028
+ return {"ok": False, "error": "unauthorized"}
1029
+ connected = _srv()._user_llm_providers_for_picker(user)
1030
+ billing = str(billing_provider or "").strip().lower()
1031
+ if not billing:
1032
+ billing = _resolve_billing_provider_for_model(model_id, connected)
1033
+ if not billing and not _srv()._user_llm_has_provider(user, workspace_id):
1034
+ return {"ok": False, "error": "connect an LLM provider first"}
1035
+ user_prefs.write_user_llm_prefs(user, primary=model_id)
1036
+ return {
1037
+ "ok": True,
1038
+ "profile": "",
1039
+ "model": model_id,
1040
+ "provider": billing or "",
1041
+ "billing_provider": billing or "",
1042
+ "selection_only": True,
1043
+ }
1044
+ target = profile or _srv()._primary_profile()
1045
+ if not target:
1046
+ return {"ok": False, "error": "no profile resolved"}
1047
+ if not model_id or " " in model_id or "\n" in model_id:
1048
+ return {"ok": False, "error": "invalid model id"}
1049
+ user = str(user_id or "").strip()
1050
+ ws = str(workspace_id or "").strip()
1051
+ connected = _srv()._user_llm_providers_for_picker(user) if user else set()
1052
+ billing = str(billing_provider or "").strip().lower()
1053
+ if not billing:
1054
+ prefer = _llm_billing_provider(target, user_id=user, workspace_id=ws) if target else ""
1055
+ billing = _resolve_billing_provider_for_model(model_id, connected, prefer=prefer)
1056
+ if billing:
1057
+ ok, err = _apply_model_persisted(
1058
+ target, billing, model_id, user, restart_gateway=False,
1059
+ )
1060
+ if not ok:
1061
+ return {"ok": False, "error": err}
1062
+ else:
1063
+ ok, err = _apply_model_persisted(
1064
+ target, "", model_id, user, restart_gateway=False,
1065
+ )
1066
+ if not ok:
1067
+ return {"ok": False, "error": err}
1068
+ tpl = _srv().safe_profile_slug(target)
1069
+ ok, err = _mirror_template_model_to_runtime(user, tpl, billing, model_id)
1070
+ if not ok:
1071
+ return {"ok": False, "error": err}
1072
+ reload_prof = ""
1073
+ if _srv()._is_runtime_profile_slug(target):
1074
+ reload_prof = target
1075
+ elif user:
1076
+ runtime = _srv()._runtime_profile_slug(user, tpl)
1077
+ if _srv()._runtime_profile_on_disk(runtime):
1078
+ reload_prof = runtime
1079
+ if reload_prof:
1080
+ _srv()._schedule_gateway_reload(reload_prof)
1081
+ block = _read_model_block(target)
1082
+ resolved_billing = billing or _billing_provider_id_from_hermes_config(
1083
+ str(block.get("provider") or "")
1084
+ ) or _llm_billing_provider(target, user_id=user, workspace_id=ws)
1085
+ persisted = _persisted_model_id(model_id, billing or resolved_billing)
1086
+ return {
1087
+ "ok": True,
1088
+ "profile": target,
1089
+ "model": persisted,
1090
+ "provider": resolved_billing,
1091
+ "billing_provider": resolved_billing,
1092
+ }
1093
+
1094
+
1095
+ def hermes_fallback_chain_set(
1096
+ profile: str,
1097
+ chain: list[Any],
1098
+ *,
1099
+ selection_only: bool = False,
1100
+ user_id: str = "",
1101
+ ) -> dict[str, Any]:
1102
+ """Set the fallback chain for a profile. Each entry must have
1103
+ `provider` and `model` keys. Order matters: Hermes tries them in
1104
+ sequence when the primary fails.
1105
+ """
1106
+ normalized: list[dict[str, str]] = []
1107
+ for entry in chain:
1108
+ if not isinstance(entry, dict):
1109
+ continue
1110
+ provider = str(entry.get("provider", "")).strip()
1111
+ model = str(entry.get("model", "")).strip()
1112
+ if not provider or not model or " " in model or "\n" in model:
1113
+ return {
1114
+ "ok": False,
1115
+ "error": f"invalid fallback entry: provider={provider!r} model={model!r}",
1116
+ }
1117
+ normalized.append({"provider": provider, "model": model})
1118
+ if selection_only:
1119
+ user = str(user_id or "").strip()
1120
+ if not user:
1121
+ return {"ok": False, "error": "unauthorized"}
1122
+ user_prefs.write_user_llm_prefs(user, fallback_chain=normalized)
1123
+ return {"ok": True, "profile": "", "fallback_chain": normalized, "selection_only": True}
1124
+ target = profile or _srv()._primary_profile()
1125
+ if not target:
1126
+ return {"ok": False, "error": "no profile resolved"}
1127
+ ok, err = _write_fallback_chain(target, normalized)
1128
+ if not ok:
1129
+ return {"ok": False, "error": err}
1130
+ user = str(user_id or "").strip()
1131
+ tpl = _srv().safe_profile_slug(target)
1132
+ if user and not _srv()._is_runtime_profile_slug(tpl):
1133
+ runtime = _srv()._runtime_profile_slug(user, tpl)
1134
+ if _srv()._runtime_profile_on_disk(runtime):
1135
+ ok, err = _write_fallback_chain(runtime, normalized)
1136
+ if not ok:
1137
+ return {"ok": False, "error": err or "runtime fallback apply failed"}
1138
+ _srv()._schedule_gateway_reload(runtime)
1139
+ return {"ok": True, "profile": target, "fallback_chain": normalized}