create-workframe 0.1.18 → 0.1.20

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 (133) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +73 -9
  3. package/package.json +1 -1
  4. package/workframe-api/action_proxy.py +8 -0
  5. package/workframe-api/chat_sessions.py +37 -6
  6. package/workframe-api/chat_stream.py +16 -0
  7. package/workframe-api/db_schema.py +29 -0
  8. package/workframe-api/handler_modules/handler_admin.py +32 -0
  9. package/workframe-api/handler_modules/handler_auth.py +6 -4
  10. package/workframe-api/handler_modules/handler_chat.py +17 -26
  11. package/workframe-api/handler_modules/handler_provider.py +13 -14
  12. package/workframe-api/handler_modules/handler_workspace.py +60 -2
  13. package/workframe-api/hermes_profiles.py +31 -9
  14. package/workframe-api/llm_proxy.py +18 -0
  15. package/workframe-api/mention_invoke.py +29 -24
  16. package/workframe-api/message_reactions.py +139 -0
  17. package/workframe-api/model_surface.py +329 -113
  18. package/workframe-api/package.json +4 -4
  19. package/workframe-api/profile_gateway.py +14 -6
  20. package/workframe-api/provider_bindings.py +118 -17
  21. package/workframe-api/provider_bootstrap.py +74 -38
  22. package/workframe-api/provider_model_catalog.py +261 -0
  23. package/workframe-api/rooms.py +4 -3
  24. package/workframe-api/route_registry.py +4 -0
  25. package/workframe-api/runtime_cohort.py +118 -23
  26. package/workframe-api/scripts/run-python.mjs +28 -0
  27. package/workframe-api/server.py +44 -12
  28. package/workframe-api/test_agent_identity_authority.py +96 -0
  29. package/workframe-api/test_chat_attribution_history.py +59 -0
  30. package/workframe-api/test_enrich_room_messages.py +43 -2
  31. package/workframe-api/test_llm_proxy_encoding.py +33 -0
  32. package/workframe-api/test_message_reactions.py +151 -0
  33. package/workframe-api/test_model_routing_reconcile.py +122 -0
  34. package/workframe-api/test_model_surface_consistency.py +45 -5
  35. package/workframe-api/test_oauth_llm.py +1 -1
  36. package/workframe-api/test_profile_gateway_health.py +37 -0
  37. package/workframe-api/test_provider_bindings.py +115 -1
  38. package/workframe-api/test_provider_model_catalog.py +27 -0
  39. package/workframe-api/test_provider_model_surface.py +2 -2
  40. package/workframe-api/test_runtime_lease_lifecycle.py +93 -0
  41. package/workframe-api/test_server_reexports.py +2 -0
  42. package/workframe-api/test_user_llm_prefs.py +27 -0
  43. package/workframe-api/test_workspace_file_actions.py +119 -0
  44. package/workframe-api/turn_credentials.py +41 -0
  45. package/workframe-api/turn_overlay.py +95 -34
  46. package/workframe-api/user_prefs.py +15 -0
  47. package/workframe-api/workspace_bootstrap.py +1 -0
  48. package/workframe-api/workspace_files.py +124 -0
  49. package/workframe-ui/public/assets/{arc-CSddOtMa.js → arc-BkATcoqt.js} +1 -1
  50. package/workframe-ui/public/assets/architecture-7EHR7CIX-yusDFKmK.js +1 -0
  51. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-BSiUei3D.js → architectureDiagram-3BPJPVTR-31_yAiUS.js} +1 -1
  52. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BhKTULPo.js → blockDiagram-GPEHLZMM-ClM6Si9T.js} +1 -1
  53. package/workframe-ui/public/assets/{brave-B885JYnH.svg → brave-BoYMbZi1.svg} +0 -1
  54. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Bv_xZi-a.js → c4Diagram-AAUBKEIU-DJzEktF3.js} +1 -1
  55. package/workframe-ui/public/assets/channel-Nb_UjZ3O.js +1 -0
  56. package/workframe-ui/public/assets/{chunk-2J33WTMH-Bh0WiqFE.js → chunk-2J33WTMH-DPFiyb5O.js} +1 -1
  57. package/workframe-ui/public/assets/{chunk-3OPIFGDE-qN09pRuK.js → chunk-3OPIFGDE-DXNJzuCn.js} +1 -1
  58. package/workframe-ui/public/assets/{chunk-4BX2VUAB-Dz6FVuMa.js → chunk-4BX2VUAB-DDNibGdr.js} +1 -1
  59. package/workframe-ui/public/assets/{chunk-55IACEB6-DWtPHVfR.js → chunk-55IACEB6-Co3_ulwA.js} +1 -1
  60. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-CyOUeDAo.js → chunk-5ZQYHXKU-Cmxz8o4d.js} +1 -1
  61. package/workframe-ui/public/assets/{chunk-727SXJPM-BrICvWwx.js → chunk-727SXJPM-CDAQGesU.js} +1 -1
  62. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CQu2SmLM.js → chunk-AQP2D5EJ-x-swy3kc.js} +1 -1
  63. package/workframe-ui/public/assets/{chunk-BSJP7CBP-uJbYXNNG.js → chunk-BSJP7CBP-28nU0JOE.js} +1 -1
  64. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-CCS_FtVZ.js → chunk-CSCIHK7Q-FUURFika.js} +1 -1
  65. package/workframe-ui/public/assets/{chunk-FMBD7UC4-CoLUtPiK.js → chunk-FMBD7UC4-B4RgRO2R.js} +1 -1
  66. package/workframe-ui/public/assets/{chunk-KSCS5N6A-DLW4gvEW.js → chunk-KSCS5N6A-CkuppW3T.js} +1 -1
  67. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CMt0LJAd.js → chunk-L5ZTLDWV-DyrPSaz3.js} +1 -1
  68. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Bn-maIlt.js → chunk-LZXEDZCA-DFaZSDNV.js} +2 -2
  69. package/workframe-ui/public/assets/{chunk-ND2GUHAM-CG7yoApR.js → chunk-ND2GUHAM-DXI0OJxg.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-NZK2D7GU-B4dIvbii.js → chunk-NZK2D7GU-CyOp1ZG2.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-O5CBEL6O-B5spDyXy.js → chunk-O5CBEL6O-tZZMCfbx.js} +1 -1
  72. package/workframe-ui/public/assets/chunk-QZHKN3VN-C_TtOXi5.js +1 -0
  73. package/workframe-ui/public/assets/chunk-WU5MYG2G-BjSJQEIY.js +1 -0
  74. package/workframe-ui/public/assets/{chunk-XPW4576I-D5c0TMX6.js → chunk-XPW4576I-B7_2gS3x.js} +1 -1
  75. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-JD6iZUGh.js +1 -0
  76. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-JD6iZUGh.js +1 -0
  77. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-DFRM3DZv.js → cose-bilkent-S5V4N54A-BWJaHw_L.js} +1 -1
  78. package/workframe-ui/public/assets/{dagre-BM42HDAG-CnUdKeJa.js → dagre-BM42HDAG-Bu5MadGV.js} +1 -1
  79. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ds3-e-hW.js → diagram-2AECGRRQ-ckLZkDFD.js} +1 -1
  80. package/workframe-ui/public/assets/{diagram-5GNKFQAL-B41zx8FF.js → diagram-5GNKFQAL-Cf6Abx1Y.js} +1 -1
  81. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D83tI9I8.js → diagram-KO2AKTUF-TLk7_5mJ.js} +1 -1
  82. package/workframe-ui/public/assets/{diagram-LMA3HP47-h_EuFyLI.js → diagram-LMA3HP47-ouNxsAb0.js} +1 -1
  83. package/workframe-ui/public/assets/{diagram-OG6HWLK6-CpU-d3RZ.js → diagram-OG6HWLK6-BMCBQ35D.js} +1 -1
  84. package/workframe-ui/public/assets/{dist-_igeSJ9r.js → dist-C9bxMexz.js} +1 -1
  85. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-jLFoncRU.js → erDiagram-TEJ5UH35-BN3e6jb1.js} +1 -1
  86. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bqd_sTz0.js +1 -0
  87. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-C9IaNgCy.js → flowDiagram-I6XJVG4X-BiinzZrd.js} +1 -1
  88. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-A9FhfFc5.js → ganttDiagram-6RSMTGT7-AYE8oBBG.js} +1 -1
  89. package/workframe-ui/public/assets/{gemini-Cq-0-rmr.svg → gemini-DqnZlvMo.svg} +0 -1
  90. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BqK_IjZb.js → gitGraph-WXDBUCRP-mIq_E2vs.js} +1 -1
  91. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CDVqxhyW.js → gitGraphDiagram-PVQCEYII-Cf8IFEcO.js} +1 -1
  92. package/workframe-ui/public/assets/index-BnfFRGR3.js +133 -0
  93. package/workframe-ui/public/assets/index-COshPjF3.css +1 -0
  94. package/workframe-ui/public/assets/{info-J43DQDTF-C2EKRglL.js → info-J43DQDTF--_3kCaIQ.js} +1 -1
  95. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-azQtFzpU.js → infoDiagram-5YYISTIA-BuIOyYMu.js} +1 -1
  96. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BuDOLv1t.js → ishikawaDiagram-YF4QCWOH-D3bbsXQ8.js} +1 -1
  97. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-ypTHFoT0.js → journeyDiagram-JHISSGLW-BrZ26C8L.js} +1 -1
  98. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Cgr78J_x.js → kanban-definition-UN3LZRKU-CJiUyUOr.js} +1 -1
  99. package/workframe-ui/public/assets/{line-CcwgaMdv.js → line-Bee1snmU.js} +1 -1
  100. package/workframe-ui/public/assets/{linear-CswIlWaW.js → linear-dfD-zlI7.js} +1 -1
  101. package/workframe-ui/public/assets/{mermaid-parser.core-CQYhP37X.js → mermaid-parser.core-D6IqqDSH.js} +2 -2
  102. package/workframe-ui/public/assets/{mermaid.core-Cz1XNBaK.js → mermaid.core-CuuxYt2r.js} +3 -3
  103. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BJLu0829.js → mindmap-definition-RKZ34NQL-T7W2fkwA.js} +1 -1
  104. package/workframe-ui/public/assets/{packet-YPE3B663-AB1qh96V.js → packet-YPE3B663-CiXMDasj.js} +1 -1
  105. package/workframe-ui/public/assets/{pie-LRSECV5Y-CokIXLdT.js → pie-LRSECV5Y-CUx1_96f.js} +1 -1
  106. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyrwQn5F.js → pieDiagram-4H26LBE5-DfR96FUO.js} +1 -1
  107. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-C_Fdg3F6.js → quadrantDiagram-W4KKPZXB-Dj6mqgQE.js} +1 -1
  108. package/workframe-ui/public/assets/{radar-GUYGQ44K-C21Xfrrg.js → radar-GUYGQ44K-DKT2q98y.js} +1 -1
  109. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ChhaWgaF.js → requirementDiagram-4Y6WPE33-DYDnBVw9.js} +1 -1
  110. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-C5T4sDTt.js → sankeyDiagram-5OEKKPKP-BSkxpjjO.js} +1 -1
  111. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DRks8i3A.js → sequenceDiagram-3UESZ5HK-Dz6SNH9S.js} +1 -1
  112. package/workframe-ui/public/assets/{src-BQu5waHd.js → src-DC521nE_.js} +1 -1
  113. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BI-AKYta.js → stateDiagram-AJRCARHV-D4ZoF5NP.js} +1 -1
  114. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DknveEiR.js +1 -0
  115. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DnAIWzEc.js → timeline-definition-PNZ67QCA-d0ezg1Hu.js} +1 -1
  116. package/workframe-ui/public/assets/{treeView-BLDUP644-BEF_sjx7.js → treeView-BLDUP644-kXyaqwIy.js} +1 -1
  117. package/workframe-ui/public/assets/{treemap-LRROVOQU-BcbL7g5c.js → treemap-LRROVOQU-g168p7-O.js} +1 -1
  118. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-DAoIbCkz.js → vennDiagram-CIIHVFJN-C0_QsmRl.js} +1 -1
  119. package/workframe-ui/public/assets/{wardley-L42UT6IY-3SOmFBFw.js → wardley-L42UT6IY-BNFCdzxQ.js} +1 -1
  120. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DlQV_-LH.js → wardleyDiagram-YWT4CUSO-BOwUS9sY.js} +1 -1
  121. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-CVoTTRHF.js → xychartDiagram-2RQKCTM6-Cxr1ZiKh.js} +1 -1
  122. package/workframe-ui/public/index.html +2 -2
  123. package/workframe-ui/public/workframe-build.json +3 -3
  124. package/workframe-ui/public/assets/architecture-7EHR7CIX-CDDNM1ni.js +0 -1
  125. package/workframe-ui/public/assets/channel-R52LrLO3.js +0 -1
  126. package/workframe-ui/public/assets/chunk-QZHKN3VN-C_9MbT10.js +0 -1
  127. package/workframe-ui/public/assets/chunk-WU5MYG2G-VJadzJmf.js +0 -1
  128. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CpBjaI3g.js +0 -1
  129. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CpBjaI3g.js +0 -1
  130. package/workframe-ui/public/assets/eventmodeling-FCH6USID-kzOxPvtj.js +0 -1
  131. package/workframe-ui/public/assets/index-Bco9IDMA.css +0 -1
  132. package/workframe-ui/public/assets/index-Do34PZS2.js +0 -132
  133. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-vJyhFZbx.js +0 -1
@@ -19,7 +19,7 @@ from typing import Any
19
19
 
20
20
  from http.server import BaseHTTPRequestHandler
21
21
 
22
- import openrouter_catalog
22
+ import provider_model_catalog
23
23
  import profile_config_yaml
24
24
  import user_prefs
25
25
 
@@ -31,32 +31,50 @@ def _srv():
31
31
 
32
32
 
33
33
  def _resolve_models_profile(profile: str) -> str:
34
- """Hermes profile slug for model picker reads. Use runtime u-* when it exists on disk."""
34
+ """Hermes profile slug for model picker reads. Models belong to agent templates."""
35
35
  raw = str(profile or "").strip()
36
36
  if not raw:
37
37
  return _srv()._primary_profile()
38
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
39
  if _srv()._is_runtime_profile_slug(slug):
42
40
  return _srv().resolve_validated_profile(_srv()._runtime_template_slug(slug))
43
41
  return _srv().resolve_validated_profile(slug)
44
42
 
45
43
 
44
+ def _agent_model_profile(profile: str) -> str:
45
+ """Canonical write target for an agent's provider/model configuration."""
46
+ raw = str(profile or _srv()._primary_profile()).strip()
47
+ if not raw:
48
+ return ""
49
+ slug = _srv().safe_profile_slug(raw)
50
+ if _srv()._is_runtime_profile_slug(slug):
51
+ slug = _srv()._runtime_template_slug(slug)
52
+ return _srv().resolve_validated_profile(slug)
53
+
54
+
46
55
  def _model_suggestion_row(
47
56
  model_id: str,
48
57
  *,
49
58
  provider_label: str = "OpenRouter",
59
+ billing_provider: str = "",
50
60
  label: str = "",
51
61
  description: str = "",
52
62
  ) -> dict[str, str]:
53
63
  slug = model_id.rsplit("/", 1)[-1] if "/" in model_id else model_id
54
- return {
64
+ row = {
55
65
  "provider": provider_label,
56
66
  "model": model_id,
57
67
  "label": label or slug,
58
68
  "description": description or model_id,
59
69
  }
70
+ if billing_provider:
71
+ row["billing_provider"] = billing_provider
72
+ return row
73
+
74
+
75
+ def _model_suggestion_key(row: dict[str, str]) -> tuple[str, str]:
76
+ provider = str(row.get("billing_provider") or row.get("provider") or "").strip().lower()
77
+ return provider, str(row.get("model") or "").strip()
60
78
 
61
79
 
62
80
  def _augment_model_suggestions(
@@ -64,22 +82,23 @@ def _augment_model_suggestions(
64
82
  block: dict[str, Any],
65
83
  ) -> list[dict[str, str]]:
66
84
  """Ensure active primary and fallback chain models appear in the picker."""
67
- seen = {str(row.get("model") or "") for row in suggestions}
85
+ seen = {_model_suggestion_key(row) for row in suggestions}
68
86
  extra: list[dict[str, str]] = []
69
87
  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
- )
88
+ base_url = str(block.get("base_url") or "").strip()
89
+ configured_provider = str(block.get("provider") or "").strip()
90
+ bill = _billing_provider_from_block(base_url, configured_provider, configured_provider)
91
+ if primary and (bill.lower(), primary) not in seen:
74
92
  extra.append(
75
93
  _model_suggestion_row(
76
94
  primary,
77
95
  provider_label=_srv()._provider_display_label(bill),
78
- label="Current primary",
96
+ billing_provider=bill,
97
+ label=primary.rsplit("/", 1)[-1],
79
98
  description="Active model for this agent profile",
80
99
  ),
81
100
  )
82
- seen.add(primary)
101
+ seen.add((bill.lower(), primary))
83
102
  for entry in block.get("fallback_chain") or []:
84
103
  if not isinstance(entry, dict):
85
104
  continue
@@ -88,18 +107,19 @@ def _augment_model_suggestions(
88
107
  if not model:
89
108
  continue
90
109
  full = model if "/" in model else f"{prov}/{model}"
91
- if full in seen:
110
+ bill = _billing_provider_from_block(base_url, prov, prov)
111
+ if (bill.lower(), full) in seen:
92
112
  continue
93
- bill = _billing_provider_id_from_hermes_config(prov) or prov
94
113
  extra.append(
95
114
  _model_suggestion_row(
96
115
  full,
97
116
  provider_label=_srv()._provider_display_label(bill),
98
- label=f"Fallback · {model}",
117
+ billing_provider=bill,
118
+ label=model.rsplit("/", 1)[-1],
99
119
  description="Configured in this profile's fallback chain",
100
120
  ),
101
121
  )
102
- seen.add(full)
122
+ seen.add((bill.lower(), full))
103
123
  return extra + suggestions
104
124
 
105
125
 
@@ -116,17 +136,45 @@ def _billing_provider_from_block(base_url: str, cfg_provider: str, hint: str) ->
116
136
  "provider not recognized" bug where an OpenRouter-only user was billed to google.
117
137
  """
118
138
  h = str(hint or "").strip().lower()
139
+ mapped_hint = _billing_provider_id_from_hermes_config(h)
140
+ if mapped_hint:
141
+ return mapped_hint
119
142
  if h and h not in {"custom", "auto"}:
120
- return h
143
+ return h.replace("_", "-")
121
144
  m = _INTERNAL_LLM_PROXY_RE.search(str(base_url or ""))
122
145
  if m:
123
146
  return m.group(1).lower()
124
147
  cfg = str(cfg_provider or "").strip().lower()
148
+ mapped_cfg = _billing_provider_id_from_hermes_config(cfg)
149
+ if mapped_cfg:
150
+ return mapped_cfg
125
151
  if cfg and cfg not in {"custom", "auto"}:
126
- return cfg
152
+ return cfg.replace("_", "-")
127
153
  return "openrouter"
128
154
 
129
155
 
156
+ def _normalized_profile_fallback_chain(
157
+ block: dict[str, Any],
158
+ default_billing_provider: str,
159
+ ) -> list[dict[str, str]]:
160
+ """Expose billing providers, not Hermes' internal ``custom`` transport."""
161
+ base_url = str(block.get("base_url") or "").strip()
162
+ normalized: list[dict[str, str]] = []
163
+ for entry in block.get("fallback_chain") or []:
164
+ if not isinstance(entry, dict):
165
+ continue
166
+ model = str(entry.get("model") or "").strip()
167
+ configured = str(entry.get("provider") or "").strip()
168
+ if not model:
169
+ continue
170
+ provider = _billing_provider_from_block(base_url, configured, configured)
171
+ normalized.append({
172
+ "provider": provider or default_billing_provider,
173
+ "model": model,
174
+ })
175
+ return normalized
176
+
177
+
130
178
  def _llm_billing_provider(
131
179
  profile: str,
132
180
  provider_hint: str = "",
@@ -147,10 +195,9 @@ def _llm_billing_provider(
147
195
  candidate = _billing_provider_from_block(
148
196
  str(block.get("base_url") or ""), cfg_provider, provider_hint,
149
197
  )
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]
198
+ # Provider/model are agent-owned. Credential availability decides whether
199
+ # the acting user may run the agent; it must never silently reroute the
200
+ # agent to a different provider and create DM/room drift.
154
201
  return candidate or "openrouter"
155
202
 
156
203
 
@@ -216,9 +263,8 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
216
263
  "fallbacks": [{"provider": "deepseek", "model": "deepseek-chat"}],
217
264
  },
218
265
  "codex": {
219
- "primary": "gpt-5.4-medium",
266
+ "primary": "gpt-5.4-mini",
220
267
  "fallbacks": [
221
- {"provider": "openai-codex", "model": "gpt-5.4-medium"},
222
268
  {"provider": "openai-codex", "model": "gpt-5.4-mini"},
223
269
  ],
224
270
  },
@@ -231,7 +277,8 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
231
277
 
232
278
  # ponytail: billing id (connect catalog) ↔ Hermes config model.provider ↔ picker rows
233
279
  _PROVIDER_CATALOG_TAGS: dict[str, frozenset[str]] = {
234
- "codex": frozenset({"codex", "openai"}),
280
+ # ChatGPT-account Codex has a narrower model surface than direct OpenAI API.
281
+ "codex": frozenset({"codex"}),
235
282
  "openai": frozenset({"openai"}),
236
283
  "openrouter": frozenset({"openrouter"}),
237
284
  "anthropic": frozenset({"anthropic"}),
@@ -241,7 +288,6 @@ _PROVIDER_CATALOG_TAGS: dict[str, frozenset[str]] = {
241
288
  }
242
289
 
243
290
  _CODEX_EXTRA_MODELS: tuple[tuple[str, str, str], ...] = (
244
- ("gpt-5.4-medium", "GPT-5.4 Medium", "Codex default — agentic, tool-capable."),
245
291
  ("gpt-5.4-mini", "GPT-5.4 Mini", "Faster Codex model on ChatGPT account."),
246
292
  )
247
293
 
@@ -327,6 +373,69 @@ def _suggestions_for_connected_llm_providers(connected: set[str]) -> list[dict[s
327
373
  return out
328
374
 
329
375
 
376
+ def _live_suggestions_for_connected_llm_providers(
377
+ user_id: str,
378
+ workspace_id: str,
379
+ connected: set[str],
380
+ ) -> tuple[list[dict[str, str]], dict[str, dict[str, str]]]:
381
+ """Discover the models each effective credential can use right now."""
382
+ credentials: dict[str, str] = {}
383
+ for provider in sorted(connected):
384
+ spec = _srv()._catalog_provider_for_llm(provider) or {}
385
+ if str(spec.get("connect_mode") or "") == "oauth":
386
+ auth_id = str(spec.get("hermes_auth_id") or provider)
387
+ auth = _srv()._load_user_hermes_auth(user_id) if user_id else None
388
+ credentials[provider] = provider_model_catalog.oauth_access_token(auth, auth_id)
389
+ continue
390
+ resolved = _srv()._resolve_credential(
391
+ user_id,
392
+ workspace_id,
393
+ provider,
394
+ user_only=_srv()._provider_user_only(provider),
395
+ )
396
+ credentials[provider] = _srv()._credential_secret(resolved or {}, user_id) if resolved else ""
397
+ return provider_model_catalog.discover_many(credentials, timeout=8)
398
+
399
+
400
+ def _default_model_from_live_catalog(
401
+ connected: set[str],
402
+ suggestions: list[dict[str, str]],
403
+ ) -> tuple[str, str]:
404
+ """Choose a valid default from the first effective provider, never a disconnected one."""
405
+ catalog_order = [
406
+ str(spec["id"]).lower()
407
+ for spec in _srv().PROVIDER_CONNECT_CATALOG
408
+ if str(spec.get("category") or "") == "llm"
409
+ and str(spec["id"]).lower() in connected
410
+ ]
411
+ for provider in catalog_order:
412
+ rows = [
413
+ row for row in suggestions
414
+ if str(row.get("billing_provider") or row.get("provider") or "").strip().lower() == provider
415
+ ]
416
+ if not rows:
417
+ continue
418
+ preferred = str((PROVIDER_MVP_MODELS.get(provider) or {}).get("primary") or "").strip()
419
+ if preferred and any(str(row.get("model") or "") == preferred for row in rows):
420
+ return preferred, provider
421
+ return str(rows[0].get("model") or "").strip(), provider
422
+ return "", ""
423
+
424
+
425
+ def _effective_fallback_chain(chain: list[dict[str, str]], connected: set[str]) -> list[dict[str, str]]:
426
+ effective: list[dict[str, str]] = []
427
+ for entry in chain:
428
+ if not isinstance(entry, dict):
429
+ continue
430
+ provider = _billing_provider_id_from_hermes_config(str(entry.get("provider") or "")) or str(
431
+ entry.get("provider") or ""
432
+ ).strip().lower()
433
+ model = str(entry.get("model") or "").strip()
434
+ if provider in connected and model:
435
+ effective.append({"provider": provider, "model": model})
436
+ return effective
437
+
438
+
330
439
  def _model_id_vendor_and_bare(model_id: str) -> tuple[str, str]:
331
440
  """Split vendor/model ids (e.g. openai-codex/gpt-5.4-mini) into billing + bare model."""
332
441
  mid = str(model_id or "").strip()
@@ -391,6 +500,64 @@ def _mirror_template_model_to_runtime(
391
500
  )
392
501
 
393
502
 
503
+ def _sync_agent_model_to_runtimes(
504
+ template_slug: str,
505
+ *,
506
+ current_user_id: str = "",
507
+ current_workspace_id: str = "",
508
+ ) -> tuple[bool, str, list[str]]:
509
+ """Mirror one agent model surface to every existing per-user runtime.
510
+
511
+ Runtime profiles isolate credentials and gateways, not agent preferences.
512
+ Owner-specific credentials are refreshed when their identity is resolvable.
513
+ """
514
+ template = _agent_model_profile(template_slug)
515
+ if not template:
516
+ return False, "no profile resolved", []
517
+ profiles_dir = _srv().HERMES_DATA / "profiles"
518
+ runtimes: set[str] = set()
519
+ if profiles_dir.is_dir():
520
+ for path in profiles_dir.iterdir():
521
+ if not path.is_dir() or not _srv()._is_runtime_profile_slug(path.name):
522
+ continue
523
+ try:
524
+ if _srv()._runtime_template_slug(path.name) == template:
525
+ runtimes.add(path.name)
526
+ except ValueError:
527
+ continue
528
+ user = str(current_user_id or "").strip()
529
+ if user:
530
+ candidate = _srv()._runtime_profile_slug(user, template)
531
+ if _srv()._runtime_profile_on_disk(candidate):
532
+ runtimes.add(candidate)
533
+
534
+ synced: list[str] = []
535
+ for runtime in sorted(runtimes):
536
+ owner = _srv()._resolve_runtime_owner(runtime)
537
+ owner_id = str(owner[0] if owner else "").strip()
538
+ workspace_id = str(owner[1] if owner else "").strip()
539
+ if not owner_id and user and runtime == _srv()._runtime_profile_slug(user, template):
540
+ owner_id = user
541
+ workspace_id = str(current_workspace_id or "").strip()
542
+ ok, err = _sync_runtime_model_from_template(
543
+ runtime,
544
+ template,
545
+ user_id=owner_id,
546
+ )
547
+ if not ok:
548
+ return False, err or f"runtime model sync failed: {runtime}", synced
549
+ if owner_id:
550
+ _srv()._prepare_runtime_profile_credentials(
551
+ runtime,
552
+ owner_id,
553
+ workspace_id,
554
+ wait_healthy=False,
555
+ )
556
+ _srv()._schedule_gateway_reload(runtime)
557
+ synced.append(runtime)
558
+ return True, "", synced
559
+
560
+
394
561
  def _resolve_billing_provider_for_model(
395
562
  model_id: str,
396
563
  connected: set[str],
@@ -568,6 +735,10 @@ def _bootstrap_model_after_llm_connect(
568
735
  _apply_mvp_model_for_provider(runtime, provider_key)
569
736
  elif user:
570
737
  _srv()._reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
738
+ if user:
739
+ _srv()._refresh_user_runtime_credentials(user, ws, wait_healthy=False)
740
+ elif ws:
741
+ _srv()._refresh_workspace_runtime_credentials(ws, wait_healthy=False)
571
742
 
572
743
 
573
744
  # Curated snapshot of commonly-used models per provider. Surfaced in the
@@ -600,8 +771,6 @@ HERMES_MODEL_CATALOG: list[dict[str, str]] = [
600
771
  "label": "o1 mini", "description": "Cheaper reasoning."},
601
772
  {"provider": "OpenAI", "model": "gpt-5.4-mini",
602
773
  "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
774
  {"provider": "Codex", "model": "gpt-5.4-mini",
606
775
  "label": "GPT-5.4 Mini (Codex)", "description": "Faster Codex model."},
607
776
  {"provider": "Google", "model": "gemini-2.5-pro",
@@ -699,6 +868,7 @@ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
699
868
  in_model = False
700
869
  in_fallback = False
701
870
  fallback_indent = -1
871
+ fallback_item_indent = -1
702
872
  saw_top_model = False
703
873
  for raw in lines:
704
874
  stripped = raw.rstrip()
@@ -748,6 +918,7 @@ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
748
918
  if indent == 0 and content.startswith("fallback_providers:"):
749
919
  in_fallback = True
750
920
  fallback_indent = indent
921
+ fallback_item_indent = -1
751
922
  after = content.split(":", 1)[1].strip()
752
923
  if after and (
753
924
  after in {"{}", "[]"}
@@ -757,17 +928,14 @@ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
757
928
  in_fallback = False
758
929
  continue
759
930
 
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.
931
+ if (
932
+ in_fallback
933
+ and content.startswith("- ")
934
+ and indent in {fallback_indent, fallback_indent + 2}
935
+ ):
936
+ # PyYAML emits root sequences without indentation by default,
937
+ # while hand-authored configs commonly indent the same item.
938
+ fallback_item_indent = indent
771
939
  inline = content[2:].strip()
772
940
  entry: dict[str, str] = {}
773
941
  if ":" in inline:
@@ -776,7 +944,12 @@ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
776
944
  out["fallback_chain"].append(entry)
777
945
  continue
778
946
 
779
- if in_fallback and indent > fallback_indent + 2 and ":" in content:
947
+ if (
948
+ in_fallback
949
+ and fallback_item_indent >= 0
950
+ and indent > fallback_item_indent
951
+ and ":" in content
952
+ ):
780
953
  k, _, v = content.partition(":")
781
954
  key = k.strip()
782
955
  val = v.strip().strip('"').strip("'")
@@ -784,7 +957,16 @@ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
784
957
  out["fallback_chain"][-1][key] = val
785
958
  continue
786
959
 
787
- if in_fallback and indent <= fallback_indent:
960
+ if in_fallback and indent == fallback_indent + 2 and ":" in content and not content.startswith("- "):
961
+ # ponytail: wizard once wrote flat `model:` keys (no list) — tolerate one entry per line
962
+ k, _, v = content.partition(":")
963
+ key = k.strip()
964
+ val = v.strip().strip('"').strip("'")
965
+ if key == "model" and val:
966
+ out["fallback_chain"].append({"model": val})
967
+ continue
968
+
969
+ if in_fallback and indent <= fallback_indent and not content.startswith("- "):
788
970
  # Exited the fallback block.
789
971
  in_fallback = False
790
972
  return out
@@ -817,29 +999,41 @@ def _read_model_block(profile: str) -> dict[str, Any]:
817
999
  return block
818
1000
 
819
1001
 
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."""
1002
+ def _sync_runtime_model_from_template(
1003
+ runtime: str,
1004
+ template: str,
1005
+ *,
1006
+ user_id: str = "",
1007
+ ) -> tuple[bool, str]:
1008
+ """Keep a credential-isolated runtime aligned with its agent template."""
822
1009
  runtime_slug = _srv().safe_profile_slug(runtime)
823
1010
  template_slug = _srv().resolve_validated_profile(template)
824
1011
  if not _srv()._is_runtime_profile_slug(runtime_slug) or not _srv().profile_exists(runtime_slug):
825
- return
1012
+ return False, "runtime profile not installed"
826
1013
  tblock = _parse_model_block_from_disk(template_slug)
827
1014
  default = str(tblock.get("default") or "").strip()
828
1015
  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)
1016
+ return False, "agent template has no model"
1017
+ configured_provider = str(tblock.get("provider") or "").strip()
1018
+ billing = _billing_provider_from_block(
1019
+ str(tblock.get("base_url") or ""),
1020
+ configured_provider,
1021
+ configured_provider,
1022
+ )
1023
+ ok, err = _apply_model_persisted(
1024
+ runtime_slug,
1025
+ billing,
1026
+ default,
1027
+ user_id=str(user_id or "").strip(),
1028
+ restart_gateway=False,
1029
+ )
1030
+ if not ok:
1031
+ return False, err
840
1032
  chain = [e for e in (tblock.get("fallback_chain") or []) if isinstance(e, dict)]
841
- if chain:
842
- _write_fallback_chain(runtime_slug, chain)
1033
+ ok, err = _write_fallback_chain(runtime_slug, chain)
1034
+ if not ok:
1035
+ return False, err
1036
+ return True, ""
843
1037
 
844
1038
 
845
1039
  def _write_fallback_chain(profile: str, chain: list[dict[str, str]]) -> tuple[bool, str]:
@@ -887,19 +1081,36 @@ def hermes_models(
887
1081
  and str(spec.get("id")).lower() in {n.lower() for n in connected}
888
1082
  for spec in _srv().PROVIDER_CONNECT_CATALOG
889
1083
  )
890
- connected_llm = picker_llm if user_id else {
1084
+ connected_llm = set(picker_llm) if user_id else {
891
1085
  str(spec["id"]).lower()
892
1086
  for spec in _srv().PROVIDER_CONNECT_CATALOG
893
1087
  if str(spec.get("category") or "") == "llm"
894
1088
  and str(spec.get("id")).lower() in {n.lower() for n in connected}
895
1089
  }
1090
+ if user_id and workspace_id:
1091
+ connected_llm.update(
1092
+ str(spec["id"]).lower()
1093
+ for spec in _srv().PROVIDER_CONNECT_CATALOG
1094
+ if str(spec.get("category") or "") == "llm"
1095
+ and _srv()._user_can_use_llm(user_id, workspace_id, str(spec["id"]))
1096
+ )
1097
+ live_suggestions, catalog_status = (
1098
+ _live_suggestions_for_connected_llm_providers(user_id, workspace_id, connected_llm)
1099
+ if user_id and connected_llm
1100
+ else ([], {})
1101
+ )
896
1102
 
897
1103
  if selection_only:
898
1104
  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 "")
1105
+ primary = user_primary
900
1106
  billing = _resolve_billing_provider_for_model(primary, connected_llm) if primary else ""
1107
+ if primary and not billing:
1108
+ primary = ""
1109
+ if not primary and has_llm:
1110
+ primary, billing = _default_model_from_live_catalog(connected_llm, live_suggestions)
1111
+ user_chain = _effective_fallback_chain(user_chain, connected_llm)
901
1112
  suggestions = _augment_model_suggestions(
902
- _suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
1113
+ live_suggestions,
903
1114
  {
904
1115
  "default": primary,
905
1116
  "provider": billing or "",
@@ -916,7 +1127,8 @@ def hermes_models(
916
1127
  "base_url": "",
917
1128
  "fallback_chain": user_chain if has_llm else [],
918
1129
  "suggestions": suggestions,
919
- "connected_providers": sorted(picker_llm if user_id else connected),
1130
+ "connected_providers": sorted(connected_llm),
1131
+ "catalog_status": catalog_status,
920
1132
  "has_llm_provider": has_llm,
921
1133
  "billing_provider": billing if has_llm else "",
922
1134
  "selection_only": True,
@@ -935,42 +1147,34 @@ def hermes_models(
935
1147
  "providers": {}, "fallback_chain": [],
936
1148
  }
937
1149
  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
1150
  primary_model = str(block.get("default") or "").strip()
943
- billing = _billing_provider_id_from_hermes_config(active_provider)
1151
+ billing = _billing_provider_from_block(
1152
+ str(block.get("base_url") or ""),
1153
+ active_provider,
1154
+ active_provider,
1155
+ )
944
1156
  if not billing and primary_model:
945
1157
  billing = _resolve_billing_provider_for_model(primary_model, connected_llm) or ""
1158
+ if not billing and user_id and primary_profile:
1159
+ billing = _llm_billing_provider(
1160
+ primary_profile, user_id=user_id, workspace_id=workspace_id,
1161
+ )
1162
+ suggestions = _augment_model_suggestions(
1163
+ live_suggestions,
1164
+ {**block, "provider": billing or active_provider},
1165
+ )
946
1166
  _, bare_primary = _model_id_vendor_and_bare(primary_model)
947
1167
  if _srv()._oauth_llm_provider_spec(billing) and bare_primary:
948
1168
  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()
1169
+ seen_models: set[tuple[str, str]] = set()
962
1170
  deduped: list[dict[str, str]] = []
963
1171
  for row in suggestions:
964
- mid = str(row.get("model") or "").strip()
965
- if not mid or mid in seen_models:
1172
+ key = _model_suggestion_key(row)
1173
+ if not key[1] or key in seen_models:
966
1174
  continue
967
- seen_models.add(mid)
1175
+ seen_models.add(key)
968
1176
  deduped.append(row)
969
1177
  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
1178
  display_provider = billing
975
1179
  return {
976
1180
  "ok": True,
@@ -978,9 +1182,10 @@ def hermes_models(
978
1182
  "primary": primary_model if has_llm else "",
979
1183
  "provider": display_provider if has_llm else "",
980
1184
  "base_url": block.get("base_url", ""),
981
- "fallback_chain": block.get("fallback_chain", []) if has_llm else [],
1185
+ "fallback_chain": _normalized_profile_fallback_chain(block, billing) if has_llm else [],
982
1186
  "suggestions": suggestions,
983
- "connected_providers": sorted(picker_llm if user_id else connected),
1187
+ "connected_providers": sorted(connected_llm),
1188
+ "catalog_status": catalog_status,
984
1189
  "has_llm_provider": has_llm,
985
1190
  "billing_provider": billing if has_llm else "",
986
1191
  "default_primary": HERMES_DEFAULT_PRIMARY,
@@ -1041,7 +1246,10 @@ def hermes_model_set(
1041
1246
  "billing_provider": billing or "",
1042
1247
  "selection_only": True,
1043
1248
  }
1044
- target = profile or _srv()._primary_profile()
1249
+ try:
1250
+ target = _agent_model_profile(profile)
1251
+ except ValueError as exc:
1252
+ return {"ok": False, "error": str(exc)}
1045
1253
  if not target:
1046
1254
  return {"ok": False, "error": "no profile resolved"}
1047
1255
  if not model_id or " " in model_id or "\n" in model_id:
@@ -1065,30 +1273,30 @@ def hermes_model_set(
1065
1273
  )
1066
1274
  if not ok:
1067
1275
  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
1276
  block = _read_model_block(target)
1082
1277
  resolved_billing = billing or _billing_provider_id_from_hermes_config(
1083
1278
  str(block.get("provider") or "")
1084
1279
  ) or _llm_billing_provider(target, user_id=user, workspace_id=ws)
1085
1280
  persisted = _persisted_model_id(model_id, billing or resolved_billing)
1281
+ _srv()._sync_agent_profile_db(
1282
+ target,
1283
+ {"model_provider": resolved_billing, "model_name": persisted},
1284
+ )
1285
+ ok, err, synced_runtimes = _sync_agent_model_to_runtimes(
1286
+ target,
1287
+ current_user_id=user,
1288
+ current_workspace_id=ws,
1289
+ )
1290
+ if not ok:
1291
+ return {"ok": False, "error": err}
1292
+ _srv()._schedule_gateway_reload(target)
1086
1293
  return {
1087
1294
  "ok": True,
1088
1295
  "profile": target,
1089
1296
  "model": persisted,
1090
1297
  "provider": resolved_billing,
1091
1298
  "billing_provider": resolved_billing,
1299
+ "synced_runtimes": synced_runtimes,
1092
1300
  }
1093
1301
 
1094
1302
 
@@ -1098,6 +1306,7 @@ def hermes_fallback_chain_set(
1098
1306
  *,
1099
1307
  selection_only: bool = False,
1100
1308
  user_id: str = "",
1309
+ workspace_id: str = "",
1101
1310
  ) -> dict[str, Any]:
1102
1311
  """Set the fallback chain for a profile. Each entry must have
1103
1312
  `provider` and `model` keys. Order matters: Hermes tries them in
@@ -1121,19 +1330,26 @@ def hermes_fallback_chain_set(
1121
1330
  return {"ok": False, "error": "unauthorized"}
1122
1331
  user_prefs.write_user_llm_prefs(user, fallback_chain=normalized)
1123
1332
  return {"ok": True, "profile": "", "fallback_chain": normalized, "selection_only": True}
1124
- target = profile or _srv()._primary_profile()
1333
+ try:
1334
+ target = _agent_model_profile(profile)
1335
+ except ValueError as exc:
1336
+ return {"ok": False, "error": str(exc)}
1125
1337
  if not target:
1126
1338
  return {"ok": False, "error": "no profile resolved"}
1127
1339
  ok, err = _write_fallback_chain(target, normalized)
1128
1340
  if not ok:
1129
1341
  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}
1342
+ ok, err, synced_runtimes = _sync_agent_model_to_runtimes(
1343
+ target,
1344
+ current_user_id=str(user_id or "").strip(),
1345
+ current_workspace_id=str(workspace_id or "").strip(),
1346
+ )
1347
+ if not ok:
1348
+ return {"ok": False, "error": err}
1349
+ _srv()._schedule_gateway_reload(target)
1350
+ return {
1351
+ "ok": True,
1352
+ "profile": target,
1353
+ "fallback_chain": normalized,
1354
+ "synced_runtimes": synced_runtimes,
1355
+ }