create-workframe 0.1.7 → 0.1.9

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 (93) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/scripts/apply-update-workframe.sh +68 -28
  4. package/scripts/compose-docker-host.sh +8 -2
  5. package/workframe-api/.dockerignore +4 -0
  6. package/workframe-api/package.json +1 -1
  7. package/workframe-api/server.py +306 -70
  8. package/workframe-api/stack_config.py +7 -7
  9. package/workframe-api/test_stack_config_install_smtp.py +5 -0
  10. package/workframe-api/updates.py +146 -33
  11. package/workframe-supervisor/Dockerfile +1 -1
  12. package/workframe-supervisor/server.py +16 -3
  13. package/workframe-ui/public/assets/{arc-Cn-lt9fT.js → arc-DjpS9a2b.js} +1 -1
  14. package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +1 -0
  15. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-qXoGCqfH.js → architectureDiagram-3BPJPVTR-puCKmKHV.js} +1 -1
  16. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-DoopFlWj.js → blockDiagram-GPEHLZMM-C2P0g-eH.js} +1 -1
  17. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BRa7hfzE.js → c4Diagram-AAUBKEIU-DdvYEITH.js} +1 -1
  18. package/workframe-ui/public/assets/channel-D63s_yN9.js +1 -0
  19. package/workframe-ui/public/assets/{chunk-2J33WTMH-Cl33E2hh.js → chunk-2J33WTMH-Cxe8QsEF.js} +1 -1
  20. package/workframe-ui/public/assets/{chunk-3OPIFGDE-BRywYZQO.js → chunk-3OPIFGDE-DPNyTwR3.js} +1 -1
  21. package/workframe-ui/public/assets/{chunk-4BX2VUAB-CI3xzp-0.js → chunk-4BX2VUAB-XKG9gfRN.js} +1 -1
  22. package/workframe-ui/public/assets/{chunk-55IACEB6-Bkf3Ye3F.js → chunk-55IACEB6-oPMTulgb.js} +1 -1
  23. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-C5RN3hUT.js → chunk-5ZQYHXKU-BWwXvpa_.js} +1 -1
  24. package/workframe-ui/public/assets/{chunk-727SXJPM-BaY0yDwt.js → chunk-727SXJPM-Cml2twO_.js} +1 -1
  25. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-VMMBaDqd.js → chunk-AQP2D5EJ-DzrRBhQu.js} +1 -1
  26. package/workframe-ui/public/assets/{chunk-BSJP7CBP-DlNZ1UV3.js → chunk-BSJP7CBP-EZ7STU6h.js} +1 -1
  27. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-tT2HyVy0.js → chunk-CSCIHK7Q-BGTQzWYw.js} +1 -1
  28. package/workframe-ui/public/assets/{chunk-FMBD7UC4-CyACRaUT.js → chunk-FMBD7UC4-Cm4stfRa.js} +1 -1
  29. package/workframe-ui/public/assets/{chunk-KSCS5N6A-seK5WAZs.js → chunk-KSCS5N6A-exbMijjy.js} +1 -1
  30. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-r5cyG5AH.js → chunk-L5ZTLDWV-C75kbQwN.js} +1 -1
  31. package/workframe-ui/public/assets/{chunk-LZXEDZCA-SnbeVxsJ.js → chunk-LZXEDZCA-CsRdBqUB.js} +2 -2
  32. package/workframe-ui/public/assets/{chunk-ND2GUHAM-zbFgEXSJ.js → chunk-ND2GUHAM-D84zyIu2.js} +1 -1
  33. package/workframe-ui/public/assets/{chunk-NZK2D7GU-UhZc8kOk.js → chunk-NZK2D7GU-CUNNo1bj.js} +1 -1
  34. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DHvu2iZ-.js → chunk-O5CBEL6O-DN6WEIh-.js} +1 -1
  35. package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +1 -0
  36. package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +1 -0
  37. package/workframe-ui/public/assets/{chunk-XPW4576I-CCPNKbzG.js → chunk-XPW4576I-Ct5VQv2D.js} +1 -1
  38. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +1 -0
  39. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +1 -0
  40. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-D1CLe6Y0.js → cose-bilkent-S5V4N54A-Bj4W5oMk.js} +1 -1
  41. package/workframe-ui/public/assets/{dagre-BM42HDAG-DYHcGVs_.js → dagre-BM42HDAG-DAjHBkAy.js} +1 -1
  42. package/workframe-ui/public/assets/{diagram-2AECGRRQ-DVUkJ-II.js → diagram-2AECGRRQ-Ch2gGFPG.js} +1 -1
  43. package/workframe-ui/public/assets/{diagram-5GNKFQAL-C9Y1Acc2.js → diagram-5GNKFQAL-BzIIvgYH.js} +1 -1
  44. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Db3zez-n.js → diagram-KO2AKTUF-Df0Q8nv6.js} +1 -1
  45. package/workframe-ui/public/assets/{diagram-LMA3HP47-BaOR5sIr.js → diagram-LMA3HP47-D-G3ekmF.js} +1 -1
  46. package/workframe-ui/public/assets/{diagram-OG6HWLK6-fLBzMY5W.js → diagram-OG6HWLK6-TBHhT9f5.js} +1 -1
  47. package/workframe-ui/public/assets/{dist-uZYeD3o1.js → dist-CPbiDkEZ.js} +1 -1
  48. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BoCxDSyE.js → erDiagram-TEJ5UH35-YxkUmLLV.js} +1 -1
  49. package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +1 -0
  50. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-BnYa3CAj.js → flowDiagram-I6XJVG4X-kfZ7-RSd.js} +1 -1
  51. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-Dm_e_Kaa.js → ganttDiagram-6RSMTGT7-DmTx3hzR.js} +1 -1
  52. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-CBAj8ntv.js → gitGraph-WXDBUCRP-BXa0ewp3.js} +1 -1
  53. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-D5auJjJh.js → gitGraphDiagram-PVQCEYII-CQr2qMxU.js} +1 -1
  54. package/workframe-ui/public/assets/{index-auJ0bLMU.css → index-B08aShJy.css} +1 -1
  55. package/workframe-ui/public/assets/index-DCF3W3G_.js +129 -0
  56. package/workframe-ui/public/assets/{info-J43DQDTF-BNMADxE2.js → info-J43DQDTF-t-Qlx8zv.js} +1 -1
  57. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C8c45T4J.js → infoDiagram-5YYISTIA-CzkYofPx.js} +1 -1
  58. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CyWuHB4e.js → ishikawaDiagram-YF4QCWOH-djK3Oxgb.js} +1 -1
  59. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-B43pCuDT.js → journeyDiagram-JHISSGLW-CWxYXxdF.js} +1 -1
  60. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Dyn480BI.js → kanban-definition-UN3LZRKU-D7leCAD9.js} +1 -1
  61. package/workframe-ui/public/assets/{line-CNtIbnmt.js → line-BSFj2OC5.js} +1 -1
  62. package/workframe-ui/public/assets/{linear-pmSg_4uV.js → linear-pfPwnu-o.js} +1 -1
  63. package/workframe-ui/public/assets/{mermaid-parser.core-BX4tnojE.js → mermaid-parser.core-Dm2L9Ib_.js} +2 -2
  64. package/workframe-ui/public/assets/{mermaid.core-BtpBva5o.js → mermaid.core-Bevx9bif.js} +3 -3
  65. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DcMcB_69.js → mindmap-definition-RKZ34NQL-UkjDssmP.js} +1 -1
  66. package/workframe-ui/public/assets/{packet-YPE3B663-KSzK9WlD.js → packet-YPE3B663-JO2ezH3_.js} +1 -1
  67. package/workframe-ui/public/assets/{pie-LRSECV5Y-DLOH6Mg8.js → pie-LRSECV5Y-DdMFsfR8.js} +1 -1
  68. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-BhrX2aGk.js → pieDiagram-4H26LBE5-CEPHFBU1.js} +1 -1
  69. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-B5dDjbTX.js → quadrantDiagram-W4KKPZXB-_RF9_86k.js} +1 -1
  70. package/workframe-ui/public/assets/{radar-GUYGQ44K-BPdjqZiu.js → radar-GUYGQ44K-Cb-YfdaA.js} +1 -1
  71. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-21Dq5e0N.js → requirementDiagram-4Y6WPE33-DBVOxmBn.js} +1 -1
  72. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CiA99dbO.js → sankeyDiagram-5OEKKPKP-BTpxsGUA.js} +1 -1
  73. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-Bwpui7_x.js → sequenceDiagram-3UESZ5HK-DHP_Bq3l.js} +1 -1
  74. package/workframe-ui/public/assets/{src-B3Axsi1U.js → src-CY_By-r7.js} +1 -1
  75. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-C76qwtE8.js → stateDiagram-AJRCARHV-BVtp_LPR.js} +1 -1
  76. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +1 -0
  77. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-CufInOyk.js → timeline-definition-PNZ67QCA-D38HJhop.js} +1 -1
  78. package/workframe-ui/public/assets/{treeView-BLDUP644-C4sxh0R-.js → treeView-BLDUP644-CFCG3Sc_.js} +1 -1
  79. package/workframe-ui/public/assets/{treemap-LRROVOQU-BjV72ANV.js → treemap-LRROVOQU-BeiMs_dF.js} +1 -1
  80. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-DgsdR0Rl.js → vennDiagram-CIIHVFJN-CKMeT6PW.js} +1 -1
  81. package/workframe-ui/public/assets/{wardley-L42UT6IY-DZ279G_t.js → wardley-L42UT6IY-BljDy8ph.js} +1 -1
  82. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-Bde25kvz.js → wardleyDiagram-YWT4CUSO-DEE8D94J.js} +1 -1
  83. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-B8VnS2Pb.js → xychartDiagram-2RQKCTM6-BRaAWV_g.js} +1 -1
  84. package/workframe-ui/public/index.html +2 -2
  85. package/workframe-ui/public/assets/architecture-7EHR7CIX-CCMNXHod.js +0 -1
  86. package/workframe-ui/public/assets/channel-CtFV3sE3.js +0 -1
  87. package/workframe-ui/public/assets/chunk-QZHKN3VN-B1g7l1_B.js +0 -1
  88. package/workframe-ui/public/assets/chunk-WU5MYG2G-B_qfZ6QP.js +0 -1
  89. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CJoeCNq6.js +0 -1
  90. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CJoeCNq6.js +0 -1
  91. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bw910jho.js +0 -1
  92. package/workframe-ui/public/assets/index-DHLQNAXd.js +0 -129
  93. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-Dn0CDWJD.js +0 -1
@@ -551,44 +551,11 @@ def _admin_write_allowed(handler: BaseHTTPRequestHandler) -> bool:
551
551
 
552
552
 
553
553
  def _apply_stack_update(target: str) -> dict[str, Any]:
554
- workframe_version = ""
555
- if target in {"workframe", "all"}:
556
- try:
557
- workframe_version = str(
558
- stack_updates.updates_available().get("workframe", {}).get("latest") or ""
559
- ).strip()
560
- except Exception: # noqa: BLE001
561
- workframe_version = ""
562
- try:
563
- return stack_updates.apply_update(target)
564
- except ValueError as exc:
565
- err = str(exc)
566
- if not _supervisor_ready():
567
- raise
568
- if err not in ("docker_unavailable", "admin_updates_disabled"):
569
- raise
570
- body: dict[str, Any] = {"target": target}
571
- if workframe_version:
572
- body["workframe_version"] = workframe_version
573
- status, data = _supervisor_request("POST", "/v1/stack.apply", body, timeout=900.0)
574
- if status >= 300 or not isinstance(data, dict) or not data.get("ok"):
575
- raise ValueError(str((data or {}).get("error") or "supervisor_apply_failed"))
576
- return data
554
+ return stack_updates.apply_update(target)
577
555
 
578
556
 
579
557
  def _restart_stack_gateway() -> dict[str, Any]:
580
- try:
581
- return stack_updates.restart_gateway()
582
- except ValueError as exc:
583
- # ponytail: SECURE_MODE API has no docker.sock; supervisor owns gateway restart
584
- if str(exc) not in ("docker_unavailable", "admin_updates_disabled") or not _supervisor_ready():
585
- raise
586
- status, data = _supervisor_request(
587
- "POST", "/v1/stack.apply", {"target": "gateway-restart"}, timeout=300.0,
588
- )
589
- if status >= 300 or not isinstance(data, dict) or not data.get("ok"):
590
- raise ValueError(str((data or {}).get("error") or "supervisor_restart_failed"))
591
- return data
558
+ return stack_updates.restart_gateway()
592
559
 
593
560
 
594
561
  def _cors_origin_for(headers=None) -> str:
@@ -908,8 +875,13 @@ def _auth_check(handler: BaseHTTPRequestHandler) -> bool:
908
875
  if method == "OPTIONS":
909
876
  return True
910
877
 
911
- # GET public routes
878
+ # GET public routes — still attach session user when cookie/header present
879
+ # (e.g. /api/hermes/models needs auth_user for per-user provider detection).
912
880
  if method == "GET" and _is_public_get(path):
881
+ if sid:
882
+ session_info = _zk.validate_session_token(sid)
883
+ if session_info:
884
+ _apply_session_user(handler, session_info["user_id"])
913
885
  return True
914
886
 
915
887
  # POST auth endpoints (start/verify/logout/refresh) are public
@@ -2076,6 +2048,53 @@ def _merge_user_platform_ids(user_id: str, patch: dict[str, str]) -> None:
2076
2048
  _apply_me_profile_updates(user_id, {"platform_ids": merged})
2077
2049
 
2078
2050
 
2051
+ def _read_user_llm_prefs(user_id: str) -> tuple[str, list[dict[str, str]]]:
2052
+ """User-level default model prefs (not agent profile config)."""
2053
+ user = _get_workframe_user(str(user_id or "").strip())
2054
+ pids = user.get("platform_ids") if user and isinstance(user.get("platform_ids"), dict) else {}
2055
+ primary = str(pids.get("llm_primary") or "").strip()
2056
+ chain_raw = pids.get("llm_fallback_chain")
2057
+ chain: list[dict[str, str]] = []
2058
+ if isinstance(chain_raw, str) and chain_raw.strip():
2059
+ try:
2060
+ parsed = json.loads(chain_raw)
2061
+ if isinstance(parsed, list):
2062
+ for entry in parsed:
2063
+ if isinstance(entry, dict):
2064
+ prov = str(entry.get("provider") or "").strip()
2065
+ model = str(entry.get("model") or "").strip()
2066
+ if prov and model:
2067
+ chain.append({"provider": prov, "model": model})
2068
+ except json.JSONDecodeError:
2069
+ pass
2070
+ return primary, chain
2071
+
2072
+
2073
+ def _write_user_llm_prefs(
2074
+ user_id: str,
2075
+ *,
2076
+ primary: str = "",
2077
+ fallback_chain: list[dict[str, str]] | None = None,
2078
+ ) -> None:
2079
+ user_id = str(user_id or "").strip()
2080
+ if not user_id:
2081
+ return
2082
+ patch: dict[str, str] = {}
2083
+ if primary:
2084
+ patch["llm_primary"] = primary.strip()
2085
+ if fallback_chain is not None:
2086
+ patch["llm_fallback_chain"] = json.dumps(fallback_chain, separators=(",", ":"))
2087
+ if patch:
2088
+ _merge_user_platform_ids(user_id, patch)
2089
+
2090
+
2091
+ def _user_llm_has_provider(user_id: str, workspace_id: str = "") -> bool:
2092
+ if _user_has_llm_provider(user_id):
2093
+ return True
2094
+ ws = str(workspace_id or "").strip()
2095
+ return bool(ws and _workspace_has_llm_provider(ws))
2096
+
2097
+
2079
2098
  def _start_discord_oauth(user_id: str, workspace_id: str = "") -> dict[str, Any]:
2080
2099
  cfg = platform_auth.resolved_discord_oauth()
2081
2100
  if not cfg.get("client_id") or not cfg.get("client_secret"):
@@ -5098,6 +5117,25 @@ def bootstrap_agent_dm_lane(
5098
5117
  try:
5099
5118
  _bootstrap_profile_providers(runtime, user_id, workspace_id)
5100
5119
  steps.append({"step": "bootstrap_providers", "ok": True})
5120
+ user_primary, user_chain = _read_user_llm_prefs(user_id)
5121
+ if user_primary:
5122
+ applied = hermes_model_set(runtime, user_primary, user_id, workspace_id)
5123
+ steps.append(
5124
+ {
5125
+ "step": "user_default_model",
5126
+ "ok": bool(applied.get("ok")),
5127
+ "model": user_primary,
5128
+ },
5129
+ )
5130
+ if user_chain:
5131
+ chained = hermes_fallback_chain_set(runtime, user_chain, user_id=user_id)
5132
+ steps.append(
5133
+ {
5134
+ "step": "user_default_fallbacks",
5135
+ "ok": bool(chained.get("ok")),
5136
+ "count": len(user_chain),
5137
+ },
5138
+ )
5101
5139
  except Exception as exc:
5102
5140
  steps.append({"step": "bootstrap_providers", "ok": False, "error": str(exc)})
5103
5141
 
@@ -6169,6 +6207,7 @@ def ensure_runtime_profile(
6169
6207
  if not _profile_toolsets_ready(runtime, _chat_toolsets_for_profile(runtime)):
6170
6208
  _ensure_profile_toolsets(runtime)
6171
6209
  _ensure_profile_terminal_cwd(runtime)
6210
+ _sync_runtime_model_from_template(runtime, template)
6172
6211
  return
6173
6212
  _purge_runtime_profile(runtime)
6174
6213
  _register_runtime_profile(runtime, template)
@@ -10678,6 +10717,28 @@ def _profile_model(profile: str) -> str:
10678
10717
  conn.close()
10679
10718
 
10680
10719
 
10720
+ def _llm_attribution_for_profile(profile: str, session_model: str = "") -> tuple[str, str]:
10721
+ """Resolve model id + billing provider for chat turn footers."""
10722
+ _ = session_model # ponytail: Hermes sessions.model is profile slug, not LLM id
10723
+ try:
10724
+ prof = resolve_hermes_profile(str(profile or "").strip())
10725
+ except ValueError:
10726
+ return "", ""
10727
+ block = _read_model_block(prof)
10728
+ model = str(block.get("default") or "").strip()
10729
+ billing = _billing_provider_id_from_hermes_config(str(block.get("provider") or ""))
10730
+ if not billing:
10731
+ billing = _llm_billing_provider(prof)
10732
+ if not billing and model:
10733
+ infer_llm = {
10734
+ str(spec["id"]).lower()
10735
+ for spec in PROVIDER_CONNECT_CATALOG
10736
+ if str(spec.get("category") or "") == "llm"
10737
+ }
10738
+ billing = _resolve_billing_provider_for_model(model, infer_llm)
10739
+ return model, str(billing or "").strip()
10740
+
10741
+
10681
10742
  def _latest_session_id(profile: str) -> str:
10682
10743
  """Most recent session — prefer active (ended_at IS NULL), then by started_at."""
10683
10744
  db = _profile_dir(profile) / "state.db"
@@ -10904,13 +10965,16 @@ def chat_messages(profile: str, session_id: str = "", source_id: str = "ui") ->
10904
10965
  _runtime_template_slug(profile) if _is_runtime_profile_slug(profile) else profile
10905
10966
  )
10906
10967
  display = _profile_display_name(profile)
10968
+ attr_model, attr_provider = _llm_attribution_for_profile(profile)
10907
10969
  try:
10908
10970
  raw = conn.execute(
10909
10971
  """
10910
- SELECT id, role, content, reasoning_content, tool_name, timestamp, token_count
10911
- FROM messages
10912
- WHERE role IN ('user', 'assistant', 'tool') AND session_id = ?
10913
- ORDER BY timestamp ASC, id ASC
10972
+ SELECT m.id, m.role, m.content, m.reasoning_content, m.tool_name,
10973
+ m.timestamp, m.token_count, s.model AS session_model
10974
+ FROM messages m
10975
+ LEFT JOIN sessions s ON s.id = m.session_id
10976
+ WHERE m.role IN ('user', 'assistant', 'tool') AND m.session_id = ?
10977
+ ORDER BY m.timestamp ASC, m.id ASC
10914
10978
  LIMIT 200
10915
10979
  """,
10916
10980
  (sid,),
@@ -10943,6 +11007,9 @@ def chat_messages(profile: str, session_id: str = "", source_id: str = "ui") ->
10943
11007
  if current and current["role"] == "agent":
10944
11008
  current["segments"].extend(segments)
10945
11009
  current["tokens"] += tokens
11010
+ if attr_model:
11011
+ current["model"] = attr_model
11012
+ current["llm_provider"] = attr_provider
10946
11013
  else:
10947
11014
  if current:
10948
11015
  turns.append(current)
@@ -10954,6 +11021,8 @@ def chat_messages(profile: str, session_id: str = "", source_id: str = "ui") ->
10954
11021
  "segments": list(segments),
10955
11022
  "timestamp": ts,
10956
11023
  "tokens": tokens,
11024
+ "model": attr_model,
11025
+ "llm_provider": attr_provider,
10957
11026
  }
10958
11027
  if current:
10959
11028
  turns.append(current)
@@ -12678,13 +12747,13 @@ def _overlay_chat_llm_env(
12678
12747
 
12679
12748
 
12680
12749
  def _resolve_models_profile(profile: str) -> str:
12681
- """Profile slug for model picker reads — accepts runtime u-* Hermes dirs."""
12750
+ """Template profile slug for model picker reads — never per-user runtime u-*."""
12682
12751
  raw = str(profile or "").strip()
12683
12752
  if not raw:
12684
12753
  return _primary_profile()
12685
12754
  slug = safe_profile_slug(raw)
12686
12755
  if _is_runtime_profile_slug(slug):
12687
- return resolve_hermes_profile(slug)
12756
+ return resolve_validated_profile(_runtime_template_slug(slug))
12688
12757
  return resolve_validated_profile(slug)
12689
12758
 
12690
12759
 
@@ -12713,10 +12782,13 @@ def _augment_model_suggestions(
12713
12782
  extra: list[dict[str, str]] = []
12714
12783
  primary = str(block.get("default") or "").strip()
12715
12784
  if primary and primary not in seen:
12785
+ bill = _billing_provider_id_from_hermes_config(str(block.get("provider") or "")) or str(
12786
+ block.get("provider") or "openrouter"
12787
+ )
12716
12788
  extra.append(
12717
12789
  _model_suggestion_row(
12718
12790
  primary,
12719
- provider_label=str(block.get("provider") or "OpenRouter").title(),
12791
+ provider_label=_provider_display_label(bill),
12720
12792
  label="Current primary",
12721
12793
  description="Active model for this agent profile",
12722
12794
  ),
@@ -12732,10 +12804,11 @@ def _augment_model_suggestions(
12732
12804
  full = model if "/" in model else f"{prov}/{model}"
12733
12805
  if full in seen:
12734
12806
  continue
12807
+ bill = _billing_provider_id_from_hermes_config(prov) or prov
12735
12808
  extra.append(
12736
12809
  _model_suggestion_row(
12737
12810
  full,
12738
- provider_label=prov.title(),
12811
+ provider_label=_provider_display_label(bill),
12739
12812
  label=f"Fallback · {model}",
12740
12813
  description="Configured in this profile's fallback chain",
12741
12814
  ),
@@ -13732,6 +13805,8 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13732
13805
  if _triggering_user:
13733
13806
  _reconcile_profile_llm_for_user(prof, _triggering_user, _workspace_id)
13734
13807
  llm_provider = _llm_billing_provider(prof, user_id=_triggering_user, workspace_id=_workspace_id)
13808
+ model_block = _read_model_block(prof)
13809
+ model_used = str(model_block.get("default") or "").strip()
13735
13810
  llm_ready = bool(
13736
13811
  _triggering_user
13737
13812
  and _user_can_use_llm(_triggering_user, _workspace_id, llm_provider)
@@ -13810,8 +13885,10 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13810
13885
  handler.send_header("X-Workframe-Api-Port", str(lifecycle["api_port"]))
13811
13886
  handler.end_headers()
13812
13887
  handler.wfile.write(
13813
- b"event: run.started\n"
13814
- b"data: {\"text\":\"Contacting model...\"}\n\n"
13888
+ (
13889
+ "event: run.started\n"
13890
+ f"data: {json.dumps({'text': 'Contacting model...', 'model': model_used, 'llm_provider': llm_provider})}\n\n"
13891
+ ).encode("utf-8")
13815
13892
  )
13816
13893
  handler.wfile.flush()
13817
13894
  buffer = b""
@@ -15122,7 +15199,7 @@ def _set_profile_model_provider(profile: str, provider: str) -> tuple[bool, str]
15122
15199
  return True, ""
15123
15200
 
15124
15201
 
15125
- def _read_model_block(profile: str) -> dict[str, Any]:
15202
+ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
15126
15203
  """Parse the model surface from the profile's config.yaml:
15127
15204
  `model.default`, `model.provider`, etc. (siblings of `model:`) AND
15128
15205
  the top-level `fallback_providers:` block. Pure text scan — no
@@ -15231,6 +15308,48 @@ def _read_model_block(profile: str) -> dict[str, Any]:
15231
15308
  return out
15232
15309
 
15233
15310
 
15311
+ def _read_model_block(profile: str) -> dict[str, Any]:
15312
+ """Model surface for a profile. Runtime u-* slugs inherit default/chain from template."""
15313
+ raw = str(profile or "").strip()
15314
+ if not raw:
15315
+ return _parse_model_block_from_disk("")
15316
+ try:
15317
+ prof = resolve_hermes_profile(raw)
15318
+ except ValueError:
15319
+ return _parse_model_block_from_disk(raw)
15320
+ block = _parse_model_block_from_disk(prof)
15321
+ if not _is_runtime_profile_slug(prof):
15322
+ return block
15323
+ template = resolve_validated_profile(_runtime_template_slug(prof))
15324
+ tblock = _parse_model_block_from_disk(template)
15325
+ if str(tblock.get("default") or "").strip():
15326
+ block["default"] = tblock["default"]
15327
+ if tblock.get("fallback_chain"):
15328
+ block["fallback_chain"] = list(tblock["fallback_chain"])
15329
+ tpl_provider = str(tblock.get("provider") or "").strip()
15330
+ if tpl_provider:
15331
+ block["provider"] = tpl_provider
15332
+ return block
15333
+
15334
+
15335
+ def _sync_runtime_model_from_template(runtime: str, template: str) -> None:
15336
+ """Keep per-user runtime Hermes config aligned with agent template model picks."""
15337
+ runtime_slug = safe_profile_slug(runtime)
15338
+ template_slug = resolve_validated_profile(template)
15339
+ if not _is_runtime_profile_slug(runtime_slug) or not profile_exists(runtime_slug):
15340
+ return
15341
+ tblock = _parse_model_block_from_disk(template_slug)
15342
+ default = str(tblock.get("default") or "").strip()
15343
+ if default:
15344
+ _set_profile_model(runtime_slug, default)
15345
+ chain = [e for e in (tblock.get("fallback_chain") or []) if isinstance(e, dict)]
15346
+ if chain:
15347
+ _write_fallback_chain(runtime_slug, chain)
15348
+ billing = _billing_provider_id_from_hermes_config(str(tblock.get("provider") or ""))
15349
+ if billing and default:
15350
+ _apply_model_for_billing_provider(runtime_slug, billing, default)
15351
+
15352
+
15234
15353
  def _write_fallback_chain(profile: str, chain: list[dict[str, str]]) -> tuple[bool, str]:
15235
15354
  """Rewrite the `fallback_providers` block in config.yaml to match the
15236
15355
  supplied chain. Preserves all other keys, comments, and ordering.
@@ -15332,24 +15451,22 @@ def _write_fallback_chain(profile: str, chain: list[dict[str, str]]) -> tuple[bo
15332
15451
  return True, ""
15333
15452
 
15334
15453
 
15335
- def hermes_models(profile: str = "", user_id: str = "", workspace_id: str = "") -> dict[str, Any]:
15454
+ def hermes_models(
15455
+ profile: str = "",
15456
+ user_id: str = "",
15457
+ workspace_id: str = "",
15458
+ *,
15459
+ selection_only: bool = False,
15460
+ ) -> dict[str, Any]:
15336
15461
  """Return a profile's model surface: primary, fallback chain,
15337
15462
  and curated suggestions scoped to the active provider. The catalog
15338
15463
  is a hint list, not a hardcoded menu — the BFF accepts any
15339
15464
  `provider/model` id and the picker always lets the user type a
15340
15465
  custom one.
15341
15466
  """
15342
- primary_profile = _resolve_models_profile(profile) if profile else _primary_profile()
15343
- if primary_profile:
15344
- _bootstrap_profile_providers(primary_profile, user_id, workspace_id)
15345
- block = _read_model_block(primary_profile) if primary_profile else {
15346
- "default": "", "provider": "", "base_url": "",
15347
- "providers": {}, "fallback_chain": [],
15348
- }
15349
- active_provider = block.get("provider", "").strip()
15350
15467
  picker_llm = _user_llm_providers_for_picker(user_id) if user_id else set()
15351
15468
  connected = _connected_provider_names(user_id, workspace_id)
15352
- has_llm = bool(picker_llm) if user_id else any(
15469
+ has_llm = _user_llm_has_provider(user_id, workspace_id) if user_id else any(
15353
15470
  str(spec.get("category") or "") == "llm"
15354
15471
  and str(spec.get("id")).lower() in {n.lower() for n in connected}
15355
15472
  for spec in PROVIDER_CONNECT_CATALOG
@@ -15360,6 +15477,48 @@ def hermes_models(profile: str = "", user_id: str = "", workspace_id: str = "")
15360
15477
  if str(spec.get("category") or "") == "llm"
15361
15478
  and str(spec.get("id")).lower() in {n.lower() for n in connected}
15362
15479
  }
15480
+
15481
+ if selection_only:
15482
+ user_primary, user_chain = _read_user_llm_prefs(user_id) if user_id else ("", [])
15483
+ primary = user_primary or (HERMES_DEFAULT_PRIMARY if has_llm else "")
15484
+ billing = _resolve_billing_provider_for_model(primary, connected_llm) if primary else ""
15485
+ suggestions = _augment_model_suggestions(
15486
+ _suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
15487
+ {
15488
+ "default": primary,
15489
+ "provider": billing or "",
15490
+ "base_url": "",
15491
+ "providers": {},
15492
+ "fallback_chain": user_chain,
15493
+ },
15494
+ )
15495
+ return {
15496
+ "ok": True,
15497
+ "profile": "",
15498
+ "primary": primary if has_llm else "",
15499
+ "provider": billing if has_llm else "",
15500
+ "base_url": "",
15501
+ "fallback_chain": user_chain if has_llm else [],
15502
+ "suggestions": suggestions,
15503
+ "connected_providers": sorted(picker_llm if user_id else connected),
15504
+ "has_llm_provider": has_llm,
15505
+ "billing_provider": billing if has_llm else "",
15506
+ "selection_only": True,
15507
+ "default_primary": HERMES_DEFAULT_PRIMARY,
15508
+ "default_fallback_chain": HERMES_DEFAULT_FALLBACK_CHAIN,
15509
+ }
15510
+
15511
+ try:
15512
+ primary_profile = _resolve_models_profile(profile) if profile else _primary_profile()
15513
+ except ValueError as exc:
15514
+ return {"ok": False, "error": str(exc)}
15515
+ if primary_profile:
15516
+ _bootstrap_profile_providers(primary_profile, user_id, workspace_id)
15517
+ block = _read_model_block(primary_profile) if primary_profile else {
15518
+ "default": "", "provider": "", "base_url": "",
15519
+ "providers": {}, "fallback_chain": [],
15520
+ }
15521
+ active_provider = block.get("provider", "").strip()
15363
15522
  suggestions = _augment_model_suggestions(
15364
15523
  _suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
15365
15524
  block,
@@ -15384,16 +15543,25 @@ def hermes_models(profile: str = "", user_id: str = "", workspace_id: str = "")
15384
15543
  seen_models.add(mid)
15385
15544
  deduped.append(row)
15386
15545
  suggestions = deduped
15546
+ primary_model = str(block.get("default") or "").strip()
15547
+ billing = _billing_provider_id_from_hermes_config(active_provider)
15548
+ if not billing and primary_model:
15549
+ billing = _resolve_billing_provider_for_model(primary_model, connected_llm) or ""
15550
+ if str(active_provider or "").lower() == "custom" and billing:
15551
+ display_provider = billing
15552
+ else:
15553
+ display_provider = active_provider
15387
15554
  return {
15388
15555
  "ok": True,
15389
15556
  "profile": primary_profile,
15390
- "primary": block.get("default", "") if has_llm else "",
15391
- "provider": active_provider if has_llm else "",
15557
+ "primary": primary_model if has_llm else "",
15558
+ "provider": display_provider if has_llm else "",
15392
15559
  "base_url": block.get("base_url", ""),
15393
15560
  "fallback_chain": block.get("fallback_chain", []) if has_llm else [],
15394
15561
  "suggestions": suggestions,
15395
15562
  "connected_providers": sorted(picker_llm if user_id else connected),
15396
15563
  "has_llm_provider": has_llm,
15564
+ "billing_provider": billing if has_llm else "",
15397
15565
  "default_primary": HERMES_DEFAULT_PRIMARY,
15398
15566
  "default_fallback_chain": HERMES_DEFAULT_FALLBACK_CHAIN,
15399
15567
  }
@@ -15428,8 +15596,27 @@ def hermes_model_set(
15428
15596
  model_id: str,
15429
15597
  user_id: str = "",
15430
15598
  workspace_id: str = "",
15599
+ *,
15600
+ selection_only: bool = False,
15431
15601
  ) -> dict[str, Any]:
15432
15602
  """Set primary model and align Hermes provider routing to a connected billing provider."""
15603
+ user = str(user_id or "").strip()
15604
+ if selection_only:
15605
+ if not user:
15606
+ return {"ok": False, "error": "unauthorized"}
15607
+ connected = _user_llm_providers_for_picker(user)
15608
+ billing = _resolve_billing_provider_for_model(model_id, connected)
15609
+ if not billing and not _user_llm_has_provider(user, workspace_id):
15610
+ return {"ok": False, "error": "connect an LLM provider first"}
15611
+ _write_user_llm_prefs(user, primary=model_id)
15612
+ return {
15613
+ "ok": True,
15614
+ "profile": "",
15615
+ "model": model_id,
15616
+ "provider": billing or "",
15617
+ "billing_provider": billing or "",
15618
+ "selection_only": True,
15619
+ }
15433
15620
  target = profile or _primary_profile()
15434
15621
  if not target:
15435
15622
  return {"ok": False, "error": "no profile resolved"}
@@ -15446,6 +15633,11 @@ def hermes_model_set(
15446
15633
  ok, err = _set_profile_model(target, model_id)
15447
15634
  if not ok:
15448
15635
  return {"ok": False, "error": err}
15636
+ tpl = safe_profile_slug(target)
15637
+ if user and not _is_runtime_profile_slug(tpl):
15638
+ runtime = _runtime_profile_slug(user, tpl)
15639
+ if _runtime_profile_on_disk(runtime):
15640
+ _sync_runtime_model_from_template(runtime, tpl)
15449
15641
  block = _read_model_block(target)
15450
15642
  return {
15451
15643
  "ok": True,
@@ -15458,14 +15650,17 @@ def hermes_model_set(
15458
15650
  }
15459
15651
 
15460
15652
 
15461
- def hermes_fallback_chain_set(profile: str, chain: list[Any]) -> dict[str, Any]:
15653
+ def hermes_fallback_chain_set(
15654
+ profile: str,
15655
+ chain: list[Any],
15656
+ *,
15657
+ selection_only: bool = False,
15658
+ user_id: str = "",
15659
+ ) -> dict[str, Any]:
15462
15660
  """Set the fallback chain for a profile. Each entry must have
15463
15661
  `provider` and `model` keys. Order matters: Hermes tries them in
15464
15662
  sequence when the primary fails.
15465
15663
  """
15466
- target = profile or _primary_profile()
15467
- if not target:
15468
- return {"ok": False, "error": "no profile resolved"}
15469
15664
  normalized: list[dict[str, str]] = []
15470
15665
  for entry in chain:
15471
15666
  if not isinstance(entry, dict):
@@ -15478,9 +15673,24 @@ def hermes_fallback_chain_set(profile: str, chain: list[Any]) -> dict[str, Any]:
15478
15673
  "error": f"invalid fallback entry: provider={provider!r} model={model!r}",
15479
15674
  }
15480
15675
  normalized.append({"provider": provider, "model": model})
15676
+ if selection_only:
15677
+ user = str(user_id or "").strip()
15678
+ if not user:
15679
+ return {"ok": False, "error": "unauthorized"}
15680
+ _write_user_llm_prefs(user, fallback_chain=normalized)
15681
+ return {"ok": True, "profile": "", "fallback_chain": normalized, "selection_only": True}
15682
+ target = profile or _primary_profile()
15683
+ if not target:
15684
+ return {"ok": False, "error": "no profile resolved"}
15481
15685
  ok, err = _write_fallback_chain(target, normalized)
15482
15686
  if not ok:
15483
15687
  return {"ok": False, "error": err}
15688
+ user = str(user_id or "").strip()
15689
+ tpl = safe_profile_slug(target)
15690
+ if user and not _is_runtime_profile_slug(tpl):
15691
+ runtime = _runtime_profile_slug(user, tpl)
15692
+ if _runtime_profile_on_disk(runtime):
15693
+ _sync_runtime_model_from_template(runtime, tpl)
15484
15694
  return {"ok": True, "profile": target, "fallback_chain": normalized}
15485
15695
 
15486
15696
 
@@ -16958,7 +17168,10 @@ class Handler(BaseHTTPRequestHandler):
16958
17168
  profile = qs.get("profile", [""])[0]
16959
17169
  user_id = str(getattr(self, "auth_user", "") or "")
16960
17170
  workspace_id = qs.get("workspace_id", [""])[0]
16961
- return self._json(200, hermes_models(profile, user_id, workspace_id))
17171
+ selection_only = qs.get("selection_only", ["0"])[0] in {"1", "true", "yes"}
17172
+ payload = hermes_models(profile, user_id, workspace_id, selection_only=selection_only)
17173
+ status = 200 if payload.get("ok", True) else 400
17174
+ return self._json(status, payload)
16962
17175
  if path == "/api/hermes/profiles/status":
16963
17176
  profile = resolve_validated_profile(qs.get("profile", [""])[0] or _primary_profile())
16964
17177
  if SUPERVISOR_URL:
@@ -18961,11 +19174,24 @@ class Handler(BaseHTTPRequestHandler):
18961
19174
  profile = str(body.get("profile", "")).strip()
18962
19175
  user_id = str(getattr(self, "auth_user", "") or "")
18963
19176
  workspace_id = str(body.get("workspace_id", "")).strip()
19177
+ selection_only = bool(body.get("selection_only"))
18964
19178
  if not model_id:
18965
19179
  return self._json(400, {"error": "model required"})
18966
- if profile:
18967
- _bootstrap_profile_providers(resolve_validated_profile(profile), user_id, workspace_id)
18968
- return self._json(200, hermes_model_set(profile, model_id, user_id, workspace_id))
19180
+ if profile and not selection_only:
19181
+ try:
19182
+ _bootstrap_profile_providers(_resolve_models_profile(profile), user_id, workspace_id)
19183
+ except ValueError as exc:
19184
+ return self._json(400, {"ok": False, "error": str(exc)})
19185
+ return self._json(
19186
+ 200,
19187
+ hermes_model_set(
19188
+ profile,
19189
+ model_id,
19190
+ user_id,
19191
+ workspace_id,
19192
+ selection_only=selection_only,
19193
+ ),
19194
+ )
18969
19195
  if path == "/api/hermes/model/apply-default":
18970
19196
  if not _check_auth(self):
18971
19197
  return self._json(401, {"error": "unauthorized"})
@@ -18975,13 +19201,23 @@ class Handler(BaseHTTPRequestHandler):
18975
19201
  if path == "/api/hermes/fallback-chain":
18976
19202
  if not _check_auth(self):
18977
19203
  return self._json(401, {"error": "unauthorized"})
18978
- if not _role_allows(self, OWNER_ADMIN_ROLES):
18979
- return self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
18980
19204
  profile = str(body.get("profile", "")).strip()
18981
19205
  chain = body.get("chain", [])
19206
+ selection_only = bool(body.get("selection_only"))
19207
+ user_id = str(getattr(self, "auth_user", "") or "")
18982
19208
  if not isinstance(chain, list):
18983
19209
  return self._json(400, {"error": "chain must be a list"})
18984
- return self._json(200, hermes_fallback_chain_set(profile, chain))
19210
+ if not selection_only and not _role_allows(self, OWNER_ADMIN_ROLES):
19211
+ return self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
19212
+ return self._json(
19213
+ 200,
19214
+ hermes_fallback_chain_set(
19215
+ profile,
19216
+ chain,
19217
+ selection_only=selection_only,
19218
+ user_id=user_id,
19219
+ ),
19220
+ )
18985
19221
  if path == "/api/board":
18986
19222
  if not _check_auth(self):
18987
19223
  return self._json(401, {"error": "unauthorized"})
@@ -251,18 +251,18 @@ def patch_stack_config(body: dict[str, Any]) -> dict[str, Any]:
251
251
 
252
252
 
253
253
  def resolve_deployment_mode(env_default: str = "trusted_team") -> str:
254
- """Explicit WORKFRAME_DEPLOYMENT_MODE env wins; stack_config applies only when env is unset."""
255
- env_raw = (os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "").strip().lower()
256
- if env_raw in DEPLOYMENT_MODES:
257
- return env_raw
258
- if env_raw:
259
- return env_raw
254
+ """Persisted stack_config deployment_mode wins when set; else WORKFRAME_DEPLOYMENT_MODE env."""
260
255
  try:
261
- sc_mode = str(get_stack_config().get("deployment_mode") or "").strip().lower()
256
+ sc_mode = str(_read_raw().get("deployment_mode") or "").strip().lower()
262
257
  if sc_mode in DEPLOYMENT_MODES:
263
258
  return sc_mode
264
259
  except Exception:
265
260
  pass
261
+ env_raw = (os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "").strip().lower()
262
+ if env_raw in DEPLOYMENT_MODES:
263
+ return env_raw
264
+ if env_raw:
265
+ return env_raw
266
266
  raw = (env_default or "trusted_team").strip().lower()
267
267
  return raw if raw in DEPLOYMENT_MODES else "trusted_team"
268
268
 
@@ -48,4 +48,9 @@ resolved = stack_config.resolved_smtp()
48
48
  assert resolved.get("host") == "smtp.ghost.example", resolved
49
49
  assert resolved.get("source") == "env", resolved
50
50
 
51
+ # Wizard deployment_mode persists over compose .env default
52
+ os.environ["WORKFRAME_DEPLOYMENT_MODE"] = "trusted_team"
53
+ stack_config.patch_stack_config({"deployment_mode": "single_user_local"})
54
+ assert stack_config.resolve_deployment_mode() == "single_user_local"
55
+
51
56
  print("stack_config install-window smtp self-check ok")