create-workframe 0.1.6 → 0.1.8

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 (90) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/workframe-api/package.json +1 -1
  4. package/workframe-api/server.py +841 -143
  5. package/workframe-api/stack_config.py +18 -10
  6. package/workframe-api/test_ensure_native_profile.py +29 -0
  7. package/workframe-api/test_oauth_llm.py +58 -0
  8. package/workframe-api/test_provider_model_surface.py +31 -0
  9. package/workframe-api/test_stack_config_install_smtp.py +56 -0
  10. package/workframe-ui/public/assets/{arc-DoYr2T5c.js → arc-DjpS9a2b.js} +1 -1
  11. package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +1 -0
  12. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-Clher08G.js → architectureDiagram-3BPJPVTR-puCKmKHV.js} +1 -1
  13. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bdw9txrZ.js → blockDiagram-GPEHLZMM-C2P0g-eH.js} +1 -1
  14. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DlI_h_uo.js → c4Diagram-AAUBKEIU-DdvYEITH.js} +1 -1
  15. package/workframe-ui/public/assets/channel-D63s_yN9.js +1 -0
  16. package/workframe-ui/public/assets/{chunk-2J33WTMH-ol-uwoeq.js → chunk-2J33WTMH-Cxe8QsEF.js} +1 -1
  17. package/workframe-ui/public/assets/{chunk-3OPIFGDE-7eDhBen0.js → chunk-3OPIFGDE-DPNyTwR3.js} +1 -1
  18. package/workframe-ui/public/assets/{chunk-4BX2VUAB-xPORD7Js.js → chunk-4BX2VUAB-XKG9gfRN.js} +1 -1
  19. package/workframe-ui/public/assets/{chunk-55IACEB6-Cwd7dr1z.js → chunk-55IACEB6-oPMTulgb.js} +1 -1
  20. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Ckt4Sjy1.js → chunk-5ZQYHXKU-BWwXvpa_.js} +1 -1
  21. package/workframe-ui/public/assets/{chunk-727SXJPM-sMk1op_G.js → chunk-727SXJPM-Cml2twO_.js} +1 -1
  22. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CMgSvr6H.js → chunk-AQP2D5EJ-DzrRBhQu.js} +1 -1
  23. package/workframe-ui/public/assets/{chunk-BSJP7CBP-Dx7GN0T4.js → chunk-BSJP7CBP-EZ7STU6h.js} +1 -1
  24. package/workframe-ui/public/assets/{chunk-CSCIHK7Q--OCu86u0.js → chunk-CSCIHK7Q-BGTQzWYw.js} +1 -1
  25. package/workframe-ui/public/assets/{chunk-FMBD7UC4-CejmwJpz.js → chunk-FMBD7UC4-Cm4stfRa.js} +1 -1
  26. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CvOj2Yvp.js → chunk-KSCS5N6A-exbMijjy.js} +1 -1
  27. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-B0nGBjLU.js → chunk-L5ZTLDWV-C75kbQwN.js} +1 -1
  28. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Bdzvu227.js → chunk-LZXEDZCA-CsRdBqUB.js} +2 -2
  29. package/workframe-ui/public/assets/{chunk-ND2GUHAM-CGR07jTY.js → chunk-ND2GUHAM-D84zyIu2.js} +1 -1
  30. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CBojDPNC.js → chunk-NZK2D7GU-CUNNo1bj.js} +1 -1
  31. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DBin5MPF.js → chunk-O5CBEL6O-DN6WEIh-.js} +1 -1
  32. package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +1 -0
  33. package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +1 -0
  34. package/workframe-ui/public/assets/{chunk-XPW4576I-TD7I7pcb.js → chunk-XPW4576I-Ct5VQv2D.js} +1 -1
  35. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +1 -0
  36. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +1 -0
  37. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bmm5x5xm.js → cose-bilkent-S5V4N54A-Bj4W5oMk.js} +1 -1
  38. package/workframe-ui/public/assets/{dagre-BM42HDAG-zU2r0aq2.js → dagre-BM42HDAG-DAjHBkAy.js} +1 -1
  39. package/workframe-ui/public/assets/{diagram-2AECGRRQ-AL01qukm.js → diagram-2AECGRRQ-Ch2gGFPG.js} +1 -1
  40. package/workframe-ui/public/assets/{diagram-5GNKFQAL-DKi1xSMg.js → diagram-5GNKFQAL-BzIIvgYH.js} +1 -1
  41. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQJDQ-f-.js → diagram-KO2AKTUF-Df0Q8nv6.js} +1 -1
  42. package/workframe-ui/public/assets/{diagram-LMA3HP47-B1oisGUj.js → diagram-LMA3HP47-D-G3ekmF.js} +1 -1
  43. package/workframe-ui/public/assets/{diagram-OG6HWLK6-DjpJKffk.js → diagram-OG6HWLK6-TBHhT9f5.js} +1 -1
  44. package/workframe-ui/public/assets/{dist-Jk20AnQG.js → dist-CPbiDkEZ.js} +1 -1
  45. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Vu8RtLUf.js → erDiagram-TEJ5UH35-YxkUmLLV.js} +1 -1
  46. package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +1 -0
  47. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CHAXEhbR.js → flowDiagram-I6XJVG4X-kfZ7-RSd.js} +1 -1
  48. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DhiEkfH8.js → ganttDiagram-6RSMTGT7-DmTx3hzR.js} +1 -1
  49. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-Cuq44XvU.js → gitGraph-WXDBUCRP-BXa0ewp3.js} +1 -1
  50. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-B7kgReRt.js → gitGraphDiagram-PVQCEYII-CQr2qMxU.js} +1 -1
  51. package/workframe-ui/public/assets/{index-auJ0bLMU.css → index-B08aShJy.css} +1 -1
  52. package/workframe-ui/public/assets/index-DCF3W3G_.js +129 -0
  53. package/workframe-ui/public/assets/{info-J43DQDTF-oV4b3M4f.js → info-J43DQDTF-t-Qlx8zv.js} +1 -1
  54. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CthN4b6o.js → infoDiagram-5YYISTIA-CzkYofPx.js} +1 -1
  55. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-oEGS3TKg.js → ishikawaDiagram-YF4QCWOH-djK3Oxgb.js} +1 -1
  56. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CXJsWmKF.js → journeyDiagram-JHISSGLW-CWxYXxdF.js} +1 -1
  57. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DTPo8yHW.js → kanban-definition-UN3LZRKU-D7leCAD9.js} +1 -1
  58. package/workframe-ui/public/assets/{line-Bm9Pmcnz.js → line-BSFj2OC5.js} +1 -1
  59. package/workframe-ui/public/assets/{linear-Cn-uD6eX.js → linear-pfPwnu-o.js} +1 -1
  60. package/workframe-ui/public/assets/{mermaid-parser.core-BBVSTQZ4.js → mermaid-parser.core-Dm2L9Ib_.js} +2 -2
  61. package/workframe-ui/public/assets/{mermaid.core-DE668nUy.js → mermaid.core-Bevx9bif.js} +3 -3
  62. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-4qVsiwCf.js → mindmap-definition-RKZ34NQL-UkjDssmP.js} +1 -1
  63. package/workframe-ui/public/assets/{packet-YPE3B663-CqJeoiFG.js → packet-YPE3B663-JO2ezH3_.js} +1 -1
  64. package/workframe-ui/public/assets/{pie-LRSECV5Y-DcOqVssg.js → pie-LRSECV5Y-DdMFsfR8.js} +1 -1
  65. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CosMnpAM.js → pieDiagram-4H26LBE5-CEPHFBU1.js} +1 -1
  66. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-DtsnIK98.js → quadrantDiagram-W4KKPZXB-_RF9_86k.js} +1 -1
  67. package/workframe-ui/public/assets/{radar-GUYGQ44K-BqLpJuWc.js → radar-GUYGQ44K-Cb-YfdaA.js} +1 -1
  68. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-Pb7cuX8_.js → requirementDiagram-4Y6WPE33-DBVOxmBn.js} +1 -1
  69. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BJBlMM1m.js → sankeyDiagram-5OEKKPKP-BTpxsGUA.js} +1 -1
  70. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-i4HnjP39.js → sequenceDiagram-3UESZ5HK-DHP_Bq3l.js} +1 -1
  71. package/workframe-ui/public/assets/{src-DIhmc_XG.js → src-CY_By-r7.js} +1 -1
  72. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-Ba2iClPT.js → stateDiagram-AJRCARHV-BVtp_LPR.js} +1 -1
  73. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +1 -0
  74. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-CL4X9vSF.js → timeline-definition-PNZ67QCA-D38HJhop.js} +1 -1
  75. package/workframe-ui/public/assets/{treeView-BLDUP644-BJrjnbMj.js → treeView-BLDUP644-CFCG3Sc_.js} +1 -1
  76. package/workframe-ui/public/assets/{treemap-LRROVOQU-CN6H54RF.js → treemap-LRROVOQU-BeiMs_dF.js} +1 -1
  77. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-Cee6sbUy.js → vennDiagram-CIIHVFJN-CKMeT6PW.js} +1 -1
  78. package/workframe-ui/public/assets/{wardley-L42UT6IY-D0cXq60c.js → wardley-L42UT6IY-BljDy8ph.js} +1 -1
  79. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CaH9XkCx.js → wardleyDiagram-YWT4CUSO-DEE8D94J.js} +1 -1
  80. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BEisgFoI.js → xychartDiagram-2RQKCTM6-BRaAWV_g.js} +1 -1
  81. package/workframe-ui/public/index.html +2 -2
  82. package/workframe-ui/public/assets/architecture-7EHR7CIX-CmDi28hh.js +0 -1
  83. package/workframe-ui/public/assets/channel-DZYVoEdD.js +0 -1
  84. package/workframe-ui/public/assets/chunk-QZHKN3VN-DwiajAaK.js +0 -1
  85. package/workframe-ui/public/assets/chunk-WU5MYG2G-Bz8vA5s7.js +0 -1
  86. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DQiq8od0.js +0 -1
  87. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DQiq8od0.js +0 -1
  88. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BXUnTe0c.js +0 -1
  89. package/workframe-ui/public/assets/index-72-j0sA5.js +0 -129
  90. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-aUpqRAhy.js +0 -1
@@ -908,8 +908,13 @@ def _auth_check(handler: BaseHTTPRequestHandler) -> bool:
908
908
  if method == "OPTIONS":
909
909
  return True
910
910
 
911
- # GET public routes
911
+ # GET public routes — still attach session user when cookie/header present
912
+ # (e.g. /api/hermes/models needs auth_user for per-user provider detection).
912
913
  if method == "GET" and _is_public_get(path):
914
+ if sid:
915
+ session_info = _zk.validate_session_token(sid)
916
+ if session_info:
917
+ _apply_session_user(handler, session_info["user_id"])
913
918
  return True
914
919
 
915
920
  # POST auth endpoints (start/verify/logout/refresh) are public
@@ -1101,13 +1106,13 @@ def _provider_connected_for_user(
1101
1106
  return False
1102
1107
 
1103
1108
 
1104
- def _profile_slug(user_id: str) -> str:
1109
+ def _user_hermes_dir_slug(user_id: str) -> str:
1105
1110
  raw = str(user_id or "").strip()
1106
1111
  return re.sub(r"[^A-Za-z0-9._-]+", "_", raw) or "user"
1107
1112
 
1108
1113
 
1109
1114
  def _user_hermes_home(user_id: str) -> Path:
1110
- return HERMES_DATA / "profiles" / _profile_slug(user_id)
1115
+ return HERMES_DATA / "profiles" / _user_hermes_dir_slug(user_id)
1111
1116
 
1112
1117
 
1113
1118
  def _user_hermes_env_path(user_id: str) -> Path:
@@ -2076,6 +2081,53 @@ def _merge_user_platform_ids(user_id: str, patch: dict[str, str]) -> None:
2076
2081
  _apply_me_profile_updates(user_id, {"platform_ids": merged})
2077
2082
 
2078
2083
 
2084
+ def _read_user_llm_prefs(user_id: str) -> tuple[str, list[dict[str, str]]]:
2085
+ """User-level default model prefs (not agent profile config)."""
2086
+ user = _get_workframe_user(str(user_id or "").strip())
2087
+ pids = user.get("platform_ids") if user and isinstance(user.get("platform_ids"), dict) else {}
2088
+ primary = str(pids.get("llm_primary") or "").strip()
2089
+ chain_raw = pids.get("llm_fallback_chain")
2090
+ chain: list[dict[str, str]] = []
2091
+ if isinstance(chain_raw, str) and chain_raw.strip():
2092
+ try:
2093
+ parsed = json.loads(chain_raw)
2094
+ if isinstance(parsed, list):
2095
+ for entry in parsed:
2096
+ if isinstance(entry, dict):
2097
+ prov = str(entry.get("provider") or "").strip()
2098
+ model = str(entry.get("model") or "").strip()
2099
+ if prov and model:
2100
+ chain.append({"provider": prov, "model": model})
2101
+ except json.JSONDecodeError:
2102
+ pass
2103
+ return primary, chain
2104
+
2105
+
2106
+ def _write_user_llm_prefs(
2107
+ user_id: str,
2108
+ *,
2109
+ primary: str = "",
2110
+ fallback_chain: list[dict[str, str]] | None = None,
2111
+ ) -> None:
2112
+ user_id = str(user_id or "").strip()
2113
+ if not user_id:
2114
+ return
2115
+ patch: dict[str, str] = {}
2116
+ if primary:
2117
+ patch["llm_primary"] = primary.strip()
2118
+ if fallback_chain is not None:
2119
+ patch["llm_fallback_chain"] = json.dumps(fallback_chain, separators=(",", ":"))
2120
+ if patch:
2121
+ _merge_user_platform_ids(user_id, patch)
2122
+
2123
+
2124
+ def _user_llm_has_provider(user_id: str, workspace_id: str = "") -> bool:
2125
+ if _user_has_llm_provider(user_id):
2126
+ return True
2127
+ ws = str(workspace_id or "").strip()
2128
+ return bool(ws and _workspace_has_llm_provider(ws))
2129
+
2130
+
2079
2131
  def _start_discord_oauth(user_id: str, workspace_id: str = "") -> dict[str, Any]:
2080
2132
  cfg = platform_auth.resolved_discord_oauth()
2081
2133
  if not cfg.get("client_id") or not cfg.get("client_secret"):
@@ -2239,7 +2291,7 @@ def _remove_auth_metadata(auth_path: Path, credential_ref: str) -> None:
2239
2291
 
2240
2292
 
2241
2293
  def _hermes_user_home_container(user_id: str) -> str:
2242
- return f"/opt/data/profiles/{_profile_slug(user_id)}"
2294
+ return f"/opt/data/profiles/{_user_hermes_dir_slug(user_id)}"
2243
2295
 
2244
2296
 
2245
2297
  def _hermes_user_exec(user_id: str, args: list[str]) -> tuple[int, str]:
@@ -2274,7 +2326,7 @@ def _hermes_user_exec(user_id: str, args: list[str]) -> tuple[int, str]:
2274
2326
  f"mkdir -p {shlex.quote(home)}; cd {shlex.quote(home)}; "
2275
2327
  f"/opt/hermes/bin/hermes {inner}"
2276
2328
  )
2277
- return _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=_profile_slug(user_id))
2329
+ return _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=_user_hermes_dir_slug(user_id))
2278
2330
 
2279
2331
 
2280
2332
  def _user_provider_bindings(user_id: str) -> dict[str, dict[str, Any]]:
@@ -2312,6 +2364,108 @@ def _hermes_auth_id_for_spec(spec: dict[str, Any]) -> str:
2312
2364
  return str(spec.get("hermes_auth_id") or spec.get("id") or "").strip()
2313
2365
 
2314
2366
 
2367
+ def _hermes_oauth_auth_keys(hermes_auth_id: str) -> set[str]:
2368
+ raw = str(hermes_auth_id or "").strip().lower()
2369
+ if not raw:
2370
+ return set()
2371
+ keys = {raw, raw.replace("-", ""), raw.replace("-", "_")}
2372
+ if "-" in raw:
2373
+ keys.add(raw.split("-")[-1])
2374
+ return {key for key in keys if key}
2375
+
2376
+
2377
+ def _oauth_llm_provider_spec(provider: str) -> dict[str, Any] | None:
2378
+ spec = _catalog_provider_for_llm(provider)
2379
+ if spec and str(spec.get("connect_mode") or "") == "oauth":
2380
+ return spec
2381
+ return None
2382
+
2383
+
2384
+ def _auth_json_has_oauth_material(data: dict[str, Any]) -> bool:
2385
+ providers = data.get("providers")
2386
+ if isinstance(providers, dict) and providers:
2387
+ return True
2388
+ pool = data.get("credential_pool")
2389
+ if isinstance(pool, dict) and any(isinstance(entries, list) and entries for entries in pool.values()):
2390
+ return True
2391
+ creds = data.get("credentials")
2392
+ return isinstance(creds, list) and bool(creds)
2393
+
2394
+
2395
+ def _extract_oauth_block_from_auth(loaded: dict[str, Any], hermes_auth_id: str) -> dict[str, Any] | None:
2396
+ keys = _hermes_oauth_auth_keys(hermes_auth_id)
2397
+ providers = loaded.get("providers")
2398
+ if isinstance(providers, dict):
2399
+ for key, entry in providers.items():
2400
+ if str(key).lower() in keys and isinstance(entry, dict):
2401
+ tokens = entry.get("tokens")
2402
+ if isinstance(tokens, dict) and any(
2403
+ str(tokens.get(field) or "").strip()
2404
+ for field in ("access_token", "refresh_token", "api_key", "id_token")
2405
+ ):
2406
+ return entry
2407
+ if entry:
2408
+ return entry
2409
+ creds = loaded.get("credentials")
2410
+ if isinstance(creds, list):
2411
+ for row in creds:
2412
+ if not isinstance(row, dict):
2413
+ continue
2414
+ pid = str(row.get("provider") or row.get("id") or "").lower()
2415
+ if pid in keys:
2416
+ return row
2417
+ return None
2418
+
2419
+
2420
+ def _merge_oauth_auth_into_profile(
2421
+ auth: dict[str, Any],
2422
+ user_auth: dict[str, Any],
2423
+ hermes_auth_id: str,
2424
+ ) -> bool:
2425
+ keys = _hermes_oauth_auth_keys(hermes_auth_id)
2426
+ changed = False
2427
+ block = _extract_oauth_block_from_auth(user_auth, hermes_auth_id)
2428
+ if isinstance(block, dict):
2429
+ merged = auth.get("providers") if isinstance(auth.get("providers"), dict) else {}
2430
+ merged[hermes_auth_id] = block
2431
+ auth["providers"] = merged
2432
+ changed = True
2433
+ user_pool = user_auth.get("credential_pool")
2434
+ if isinstance(user_pool, dict):
2435
+ pool = auth.get("credential_pool")
2436
+ if not isinstance(pool, dict):
2437
+ pool = {}
2438
+ auth["credential_pool"] = pool
2439
+ for key, entries in user_pool.items():
2440
+ if str(key).lower() in keys and isinstance(entries, list) and entries:
2441
+ pool[hermes_auth_id] = entries
2442
+ changed = True
2443
+ return changed
2444
+
2445
+
2446
+ def _sync_oauth_llm_to_profile(profile: str, user_id: str, provider: str) -> bool:
2447
+ spec = _oauth_llm_provider_spec(provider)
2448
+ if not spec:
2449
+ return False
2450
+ hermes_auth_id = _hermes_auth_id_for_spec(spec)
2451
+ if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
2452
+ return False
2453
+ user_auth = _load_user_hermes_auth(user_id)
2454
+ if not isinstance(user_auth, dict):
2455
+ return False
2456
+ prof = resolve_hermes_profile(profile)
2457
+ auth_path = _profile_dir(prof) / "auth.json"
2458
+ auth = _load_profile_auth_json(prof)
2459
+ if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
2460
+ return False
2461
+ auth["version"] = 1
2462
+ auth["updated_at"] = _utc_now()
2463
+ auth_path.parent.mkdir(parents=True, exist_ok=True)
2464
+ auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
2465
+ _publish_profile_gateway_secrets(prof)
2466
+ return True
2467
+
2468
+
2315
2469
  def _hermes_oauth_tokens_present(user_id: str, hermes_auth_id: str) -> bool:
2316
2470
  """True when Hermes auth.json has live OAuth tokens for a provider."""
2317
2471
  hermes_auth_id = str(hermes_auth_id or "").strip()
@@ -2320,23 +2474,13 @@ def _hermes_oauth_tokens_present(user_id: str, hermes_auth_id: str) -> bool:
2320
2474
  loaded = _load_user_hermes_auth(user_id)
2321
2475
  if not isinstance(loaded, dict):
2322
2476
  return False
2323
- providers = loaded.get("providers")
2324
- if isinstance(providers, dict):
2325
- for key in {hermes_auth_id, hermes_auth_id.replace("-", "")}:
2326
- entry = providers.get(key)
2327
- if not isinstance(entry, dict):
2328
- continue
2329
- tokens = entry.get("tokens")
2330
- if isinstance(tokens, dict) and any(
2331
- str(tokens.get(field) or "").strip()
2332
- for field in ("access_token", "refresh_token", "api_key", "id_token")
2333
- ):
2334
- return True
2477
+ if _extract_oauth_block_from_auth(loaded, hermes_auth_id):
2478
+ return True
2479
+ keys = _hermes_oauth_auth_keys(hermes_auth_id)
2335
2480
  pool = loaded.get("credential_pool")
2336
2481
  if isinstance(pool, dict):
2337
- for key in {hermes_auth_id, hermes_auth_id.replace("-", ""), str(hermes_auth_id).split("-")[-1]}:
2338
- entries = pool.get(key)
2339
- if isinstance(entries, list) and entries:
2482
+ for key, entries in pool.items():
2483
+ if str(key).lower() in keys and isinstance(entries, list) and entries:
2340
2484
  return True
2341
2485
  return False
2342
2486
 
@@ -2351,9 +2495,9 @@ def _load_user_hermes_auth(user_id: str) -> dict[str, Any] | None:
2351
2495
  loaded = data
2352
2496
  except (OSError, json.JSONDecodeError):
2353
2497
  loaded = None
2354
- if isinstance(loaded, dict) and isinstance(loaded.get("providers"), dict) and loaded["providers"]:
2498
+ if isinstance(loaded, dict) and _auth_json_has_oauth_material(loaded):
2355
2499
  return loaded
2356
- rel = f"profiles/{_profile_slug(user_id)}/auth.json"
2500
+ rel = f"profiles/{_user_hermes_dir_slug(user_id)}/auth.json"
2357
2501
  text = _read_gateway_data_file(rel)
2358
2502
  if text.strip():
2359
2503
  try:
@@ -2488,7 +2632,7 @@ def _remove_hermes_oauth_provider(user_id: str, hermes_auth_id: str) -> None:
2488
2632
  pool.pop(key, None)
2489
2633
  loaded["updated_at"] = _utc_now()
2490
2634
  auth_path.write_text(json.dumps(loaded, indent=2, sort_keys=True) + "\n", encoding="utf-8")
2491
- user_part = re.sub(r"[^a-z0-9]+", "-", _profile_slug(user_id).lower()).strip("-")[:20] or "user"
2635
+ user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
2492
2636
  prefix = f"u-{user_part}-"
2493
2637
  profiles_dir = HERMES_DATA / "profiles"
2494
2638
  if profiles_dir.is_dir():
@@ -2594,25 +2738,12 @@ def _parse_device_oauth_log(text: str) -> dict[str, str | None]:
2594
2738
 
2595
2739
 
2596
2740
  def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id: str) -> None:
2597
- src_path = _user_hermes_auth_path(user_id)
2598
- if not src_path.is_file():
2599
- return
2600
- try:
2601
- user_auth = json.loads(src_path.read_text(encoding="utf-8"))
2602
- except (OSError, json.JSONDecodeError):
2603
- return
2604
- providers = user_auth.get("providers") if isinstance(user_auth, dict) else None
2605
- if not isinstance(providers, dict):
2741
+ if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
2606
2742
  return
2607
- block = None
2608
- for key in (hermes_auth_id, hermes_auth_id.replace("-", "")):
2609
- entry = providers.get(key)
2610
- if isinstance(entry, dict):
2611
- block = entry
2612
- break
2613
- if not block:
2743
+ user_auth = _load_user_hermes_auth(user_id)
2744
+ if not isinstance(user_auth, dict):
2614
2745
  return
2615
- user_part = re.sub(r"[^a-z0-9]+", "-", _profile_slug(user_id).lower()).strip("-")[:20] or "user"
2746
+ user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
2616
2747
  prefix = f"u-{user_part}-"
2617
2748
  profiles_dir = HERMES_DATA / "profiles"
2618
2749
  if not profiles_dir.is_dir():
@@ -2622,9 +2753,11 @@ def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id:
2622
2753
  continue
2623
2754
  auth_path = prof_dir / "auth.json"
2624
2755
  auth = _load_profile_auth_json(prof_dir.name)
2625
- merged = auth.get("providers") if isinstance(auth.get("providers"), dict) else {}
2626
- merged[hermes_auth_id] = block
2627
- auth["providers"] = merged
2756
+ user_auth = _load_user_hermes_auth(user_id)
2757
+ if not isinstance(user_auth, dict):
2758
+ continue
2759
+ if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
2760
+ continue
2628
2761
  auth["version"] = 1
2629
2762
  auth["updated_at"] = _utc_now()
2630
2763
  auth_path.parent.mkdir(parents=True, exist_ok=True)
@@ -2743,7 +2876,7 @@ def device_oauth_status(user_id: str, provider_id: str, session_id: str) -> dict
2743
2876
  except OSError:
2744
2877
  log_text = ""
2745
2878
  if not log_text.strip() and log_path.name:
2746
- log_text = _read_gateway_data_file(f"profiles/{_profile_slug(user_id)}/{log_path.name}")
2879
+ log_text = _read_gateway_data_file(f"profiles/{_user_hermes_dir_slug(user_id)}/{log_path.name}")
2747
2880
  parsed = _parse_device_oauth_log(log_text)
2748
2881
  patch: dict[str, Any] = {}
2749
2882
  if parsed.get("verification_uri"):
@@ -4990,6 +5123,8 @@ def bootstrap_agent_dm_lane(
4990
5123
  """Provision u-* runtime, model/proxy, DM room, and optional session bind — install/create-agent parity."""
4991
5124
  user_id = str(user_id or "").strip()
4992
5125
  workspace_id = str(workspace_id or "").strip()
5126
+ if not _native_profile_present():
5127
+ _ensure_native_hermes_profile()
4993
5128
  template = resolve_validated_profile(str(template_slug or "").strip())
4994
5129
  if not user_id or not workspace_id:
4995
5130
  return {"ok": False, "error": "user_id and workspace_id required"}
@@ -5015,6 +5150,25 @@ def bootstrap_agent_dm_lane(
5015
5150
  try:
5016
5151
  _bootstrap_profile_providers(runtime, user_id, workspace_id)
5017
5152
  steps.append({"step": "bootstrap_providers", "ok": True})
5153
+ user_primary, user_chain = _read_user_llm_prefs(user_id)
5154
+ if user_primary:
5155
+ applied = hermes_model_set(runtime, user_primary, user_id, workspace_id)
5156
+ steps.append(
5157
+ {
5158
+ "step": "user_default_model",
5159
+ "ok": bool(applied.get("ok")),
5160
+ "model": user_primary,
5161
+ },
5162
+ )
5163
+ if user_chain:
5164
+ chained = hermes_fallback_chain_set(runtime, user_chain, user_id=user_id)
5165
+ steps.append(
5166
+ {
5167
+ "step": "user_default_fallbacks",
5168
+ "ok": bool(chained.get("ok")),
5169
+ "count": len(user_chain),
5170
+ },
5171
+ )
5018
5172
  except Exception as exc:
5019
5173
  steps.append({"step": "bootstrap_providers", "ok": False, "error": str(exc)})
5020
5174
 
@@ -5137,7 +5291,7 @@ def bootstrap_agent_dm_lane(
5137
5291
 
5138
5292
  def _runtime_profile_slug(user_id: str, template_slug: str) -> str:
5139
5293
  template_slug = safe_profile_slug(template_slug)
5140
- user_part = re.sub(r"[^a-z0-9]+", "-", _profile_slug(user_id).lower()).strip("-")[:20] or "user"
5294
+ user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
5141
5295
  slug = f"u-{user_part}-{template_slug}"
5142
5296
  if len(slug) > 64:
5143
5297
  slug = slug[:64].rstrip("-")
@@ -5169,7 +5323,8 @@ def _prepare_runtime_profile_credentials(
5169
5323
  return False
5170
5324
  _strip_profile_llm_env(runtime)
5171
5325
  _strip_profile_action_env(runtime)
5172
- prov = _llm_billing_provider(runtime)
5326
+ _reconcile_profile_llm_for_user(runtime, user, workspace_id)
5327
+ prov = _llm_billing_provider(runtime, user_id=user, workspace_id=workspace_id)
5173
5328
  _ensure_profile_llm_proxy(runtime, prov)
5174
5329
  return _user_can_use_llm(user, workspace_id, prov)
5175
5330
 
@@ -6085,6 +6240,7 @@ def ensure_runtime_profile(
6085
6240
  if not _profile_toolsets_ready(runtime, _chat_toolsets_for_profile(runtime)):
6086
6241
  _ensure_profile_toolsets(runtime)
6087
6242
  _ensure_profile_terminal_cwd(runtime)
6243
+ _sync_runtime_model_from_template(runtime, template)
6088
6244
  return
6089
6245
  _purge_runtime_profile(runtime)
6090
6246
  _register_runtime_profile(runtime, template)
@@ -7601,10 +7757,6 @@ def _bootstrap_after_setup(agent_personality: str = "") -> dict[str, Any]:
7601
7757
  if personality:
7602
7758
  _seed_native_user_overlay(profile, profile, user_soul=personality)
7603
7759
  result["soul_seeded"] = True
7604
- try:
7605
- result["gateway"] = profile_gateway_lifecycle(profile, "status")
7606
- except Exception as exc:
7607
- result["gateway"] = {"ok": False, "error": str(exc)}
7608
7760
  return result
7609
7761
 
7610
7762
 
@@ -7858,6 +8010,110 @@ def _list_profiles() -> list[str]:
7858
8010
  )
7859
8011
 
7860
8012
 
8013
+ def _native_profile_slug() -> str:
8014
+ return str(NATIVE_PROFILE or "workframe-agent").strip() or "workframe-agent"
8015
+
8016
+
8017
+ def _native_profile_present() -> bool:
8018
+ slug = _native_profile_slug()
8019
+ prof_dir = _profile_dir(slug)
8020
+ return (prof_dir / "config.yaml").is_file() or (prof_dir / "profile.yaml").is_file()
8021
+
8022
+
8023
+ def _hermes_data_uid_gid() -> tuple[int, int]:
8024
+ try:
8025
+ st = HERMES_DATA.stat()
8026
+ return int(st.st_uid), int(st.st_gid)
8027
+ except OSError:
8028
+ return 10000, 10000
8029
+
8030
+
8031
+ def _chown_profile_tree(prof_dir: Path) -> None:
8032
+ """Gateway runs as the Agents volume owner — seeded files must match."""
8033
+ uid, gid = _hermes_data_uid_gid()
8034
+ prof_dir.mkdir(parents=True, exist_ok=True)
8035
+ (prof_dir / "logs").mkdir(parents=True, exist_ok=True)
8036
+ for path in [prof_dir, *prof_dir.rglob("*")]:
8037
+ try:
8038
+ os.chown(path, uid, gid)
8039
+ except OSError:
8040
+ pass
8041
+
8042
+
8043
+ def _seed_native_profile_on_disk(slug: str) -> bool:
8044
+ """Filesystem fallback when Hermes CLI cannot run (empty / crash-looping gateway)."""
8045
+ slug = safe_profile_slug(slug)
8046
+ prof_dir = _profile_dir(slug)
8047
+ prof_dir.mkdir(parents=True, exist_ok=True)
8048
+ config = prof_dir / "config.yaml"
8049
+ if not config.is_file():
8050
+ proxy = _llm_proxy_base_url("openrouter")
8051
+ config.write_text(
8052
+ "model:\n"
8053
+ " default: google/gemini-2.5-flash\n"
8054
+ " provider: custom\n"
8055
+ f" base_url: {proxy}\n",
8056
+ encoding="utf-8",
8057
+ )
8058
+ soul = prof_dir / "SOUL.md"
8059
+ if not soul.is_file():
8060
+ soul.write_text(
8061
+ f"You are the {PROJECT_NAME} native agent — orchestrator and workspace admin.\n",
8062
+ encoding="utf-8",
8063
+ )
8064
+ _ensure_profile_toolsets(slug)
8065
+ _ensure_profile_terminal_cwd(slug)
8066
+ _register_profile_route(
8067
+ slug,
8068
+ {
8069
+ "display_name": f"{PROJECT_NAME} Agent",
8070
+ "role": f"{PROJECT_NAME} Manager",
8071
+ },
8072
+ )
8073
+ routes_path = ROUTES_JSON
8074
+ if routes_path.is_file():
8075
+ try:
8076
+ data = json.loads(routes_path.read_text(encoding="utf-8"))
8077
+ if isinstance(data, dict) and not data.get("default_profile"):
8078
+ data["default_profile"] = slug
8079
+ routes_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
8080
+ except (OSError, json.JSONDecodeError):
8081
+ pass
8082
+ _chown_profile_tree(prof_dir)
8083
+ return _native_profile_present()
8084
+
8085
+
8086
+ def _ensure_native_hermes_profile() -> bool:
8087
+ """Idempotent: native workframe-agent must exist before gateway boot or DM bootstrap."""
8088
+ slug = _native_profile_slug()
8089
+ if _native_profile_present():
8090
+ return True
8091
+ shell = (
8092
+ "export HERMES_HOME=/opt/data HOME=/opt/data; "
8093
+ f"/opt/hermes/bin/hermes profile create {shlex.quote(slug)} --clone "
8094
+ f"--description {shlex.quote(f'{PROJECT_NAME} native agent')}"
8095
+ )
8096
+ try:
8097
+ code, out = _gateway_container_exec(["sh", "-lc", shell])
8098
+ if code == 0 and _native_profile_present():
8099
+ _register_profile_route(
8100
+ slug,
8101
+ {"display_name": f"{PROJECT_NAME} Agent", "role": f"{PROJECT_NAME} Manager"},
8102
+ )
8103
+ _chown_profile_tree(_profile_dir(slug))
8104
+ return True
8105
+ if "already exists" in out.lower() and _native_profile_present():
8106
+ _register_profile_route(
8107
+ slug,
8108
+ {"display_name": f"{PROJECT_NAME} Agent", "role": f"{PROJECT_NAME} Manager"},
8109
+ )
8110
+ _chown_profile_tree(_profile_dir(slug))
8111
+ return True
8112
+ except Exception:
8113
+ pass
8114
+ return _seed_native_profile_on_disk(slug)
8115
+
8116
+
7861
8117
  def _primary_profile() -> str:
7862
8118
  if NATIVE_PROFILE and (HERMES_DATA / "profiles" / NATIVE_PROFILE).is_dir():
7863
8119
  return NATIVE_PROFILE
@@ -10494,6 +10750,28 @@ def _profile_model(profile: str) -> str:
10494
10750
  conn.close()
10495
10751
 
10496
10752
 
10753
+ def _llm_attribution_for_profile(profile: str, session_model: str = "") -> tuple[str, str]:
10754
+ """Resolve model id + billing provider for chat turn footers."""
10755
+ _ = session_model # ponytail: Hermes sessions.model is profile slug, not LLM id
10756
+ try:
10757
+ prof = resolve_hermes_profile(str(profile or "").strip())
10758
+ except ValueError:
10759
+ return "", ""
10760
+ block = _read_model_block(prof)
10761
+ model = str(block.get("default") or "").strip()
10762
+ billing = _billing_provider_id_from_hermes_config(str(block.get("provider") or ""))
10763
+ if not billing:
10764
+ billing = _llm_billing_provider(prof)
10765
+ if not billing and model:
10766
+ infer_llm = {
10767
+ str(spec["id"]).lower()
10768
+ for spec in PROVIDER_CONNECT_CATALOG
10769
+ if str(spec.get("category") or "") == "llm"
10770
+ }
10771
+ billing = _resolve_billing_provider_for_model(model, infer_llm)
10772
+ return model, str(billing or "").strip()
10773
+
10774
+
10497
10775
  def _latest_session_id(profile: str) -> str:
10498
10776
  """Most recent session — prefer active (ended_at IS NULL), then by started_at."""
10499
10777
  db = _profile_dir(profile) / "state.db"
@@ -10720,13 +10998,16 @@ def chat_messages(profile: str, session_id: str = "", source_id: str = "ui") ->
10720
10998
  _runtime_template_slug(profile) if _is_runtime_profile_slug(profile) else profile
10721
10999
  )
10722
11000
  display = _profile_display_name(profile)
11001
+ attr_model, attr_provider = _llm_attribution_for_profile(profile)
10723
11002
  try:
10724
11003
  raw = conn.execute(
10725
11004
  """
10726
- SELECT id, role, content, reasoning_content, tool_name, timestamp, token_count
10727
- FROM messages
10728
- WHERE role IN ('user', 'assistant', 'tool') AND session_id = ?
10729
- ORDER BY timestamp ASC, id ASC
11005
+ SELECT m.id, m.role, m.content, m.reasoning_content, m.tool_name,
11006
+ m.timestamp, m.token_count, s.model AS session_model
11007
+ FROM messages m
11008
+ LEFT JOIN sessions s ON s.id = m.session_id
11009
+ WHERE m.role IN ('user', 'assistant', 'tool') AND m.session_id = ?
11010
+ ORDER BY m.timestamp ASC, m.id ASC
10730
11011
  LIMIT 200
10731
11012
  """,
10732
11013
  (sid,),
@@ -10759,6 +11040,9 @@ def chat_messages(profile: str, session_id: str = "", source_id: str = "ui") ->
10759
11040
  if current and current["role"] == "agent":
10760
11041
  current["segments"].extend(segments)
10761
11042
  current["tokens"] += tokens
11043
+ if attr_model:
11044
+ current["model"] = attr_model
11045
+ current["llm_provider"] = attr_provider
10762
11046
  else:
10763
11047
  if current:
10764
11048
  turns.append(current)
@@ -10770,6 +11054,8 @@ def chat_messages(profile: str, session_id: str = "", source_id: str = "ui") ->
10770
11054
  "segments": list(segments),
10771
11055
  "timestamp": ts,
10772
11056
  "tokens": tokens,
11057
+ "model": attr_model,
11058
+ "llm_provider": attr_provider,
10773
11059
  }
10774
11060
  if current:
10775
11061
  turns.append(current)
@@ -11297,6 +11583,10 @@ def _ensure_profile_llm_proxy(profile: str, provider: str) -> None:
11297
11583
  """Point Hermes at the internal LLM proxy (create/lease paths only — skip when ready)."""
11298
11584
  llm_provider = str(provider or "openrouter").strip().lower()
11299
11585
  prof = safe_profile_slug(profile)
11586
+ if _oauth_llm_provider_spec(llm_provider):
11587
+ _normalize_profile_config_yaml(prof)
11588
+ _apply_mvp_model_for_provider(prof, llm_provider)
11589
+ return
11300
11590
  _normalize_profile_config_yaml(prof)
11301
11591
  if _profile_llm_proxy_ready(prof, llm_provider):
11302
11592
  _ensure_profile_proxy_headers(prof)
@@ -11449,6 +11739,16 @@ def _apply_turn_credential_lease(
11449
11739
  resolved = _require_runtime_owner_provider(user_id, workspace_id, provider)
11450
11740
  else:
11451
11741
  resolved = _require_user_provider(user_id, workspace_id, provider)
11742
+ oauth_spec = _oauth_llm_provider_spec(provider)
11743
+ if oauth_spec and str(resolved.get("credential_type") or "") == "oauth":
11744
+ _sync_oauth_llm_to_profile(prof, user_id, provider)
11745
+ current_model = str(_read_model_from_config(prof)[1] or "").strip()
11746
+ if not _resolve_billing_provider_for_model(current_model, {provider}):
11747
+ current_model = str(
11748
+ (PROVIDER_MVP_MODELS.get(provider) or {}).get("primary") or current_model
11749
+ ).strip()
11750
+ _apply_model_for_billing_provider(prof, provider, current_model, user_id)
11751
+ return ""
11452
11752
  binding_id = str(
11453
11753
  resolved.get("credential_binding_id") or resolved.get("credential_id") or ""
11454
11754
  ).strip()
@@ -11591,6 +11891,26 @@ def _require_user_provider(user_id: str, workspace_id: str, provider: str) -> di
11591
11891
 
11592
11892
  def _require_runtime_owner_provider(user_id: str, workspace_id: str, provider: str) -> dict[str, Any]:
11593
11893
  provider = str(provider or "openrouter").strip().lower()
11894
+ oauth_spec = _oauth_llm_provider_spec(provider)
11895
+ if oauth_spec and _hermes_oauth_tokens_present(user_id, _hermes_auth_id_for_spec(oauth_spec)):
11896
+ hermes_auth_id = _hermes_auth_id_for_spec(oauth_spec)
11897
+ return {
11898
+ "credential_binding_id": None,
11899
+ "credential_id": None,
11900
+ "credential_ref": f"oauth:{hermes_auth_id}",
11901
+ "scope": "user",
11902
+ "provider": provider,
11903
+ "credential_type": "oauth",
11904
+ "label": hermes_auth_id,
11905
+ "env_var": "",
11906
+ "user_id": user_id,
11907
+ "workspace_id": None,
11908
+ "agent_profile_id": None,
11909
+ "created_by": user_id,
11910
+ "created_at": None,
11911
+ "updated_at": None,
11912
+ "expires_at": None,
11913
+ }
11594
11914
  resolved = _resolve_credential(user_id, workspace_id, provider, user_only=True)
11595
11915
  if resolved and _credential_secret(resolved, user_id):
11596
11916
  return resolved
@@ -12346,8 +12666,19 @@ def _provider_pool_has_entries(auth: dict[str, Any], provider: str) -> bool:
12346
12666
  pool = auth.get("credential_pool")
12347
12667
  if not isinstance(pool, dict):
12348
12668
  return False
12349
- entries = pool.get(provider)
12350
- return isinstance(entries, list) and len(entries) > 0
12669
+ provider_key = str(provider or "").strip().lower()
12670
+ for key in _hermes_oauth_auth_keys(provider_key) | {provider_key}:
12671
+ entries = pool.get(key)
12672
+ if isinstance(entries, list) and len(entries) > 0:
12673
+ return True
12674
+ spec = _catalog_provider_for_llm(provider_key)
12675
+ if spec:
12676
+ auth_id = str(spec.get("hermes_auth_id") or "").strip().lower()
12677
+ if auth_id:
12678
+ entries = pool.get(auth_id)
12679
+ if isinstance(entries, list) and len(entries) > 0:
12680
+ return True
12681
+ return False
12351
12682
 
12352
12683
 
12353
12684
  def _connected_provider_names(user_id: str = "", workspace_id: str = "") -> set[str]:
@@ -12449,13 +12780,13 @@ def _overlay_chat_llm_env(
12449
12780
 
12450
12781
 
12451
12782
  def _resolve_models_profile(profile: str) -> str:
12452
- """Profile slug for model picker reads — accepts runtime u-* Hermes dirs."""
12783
+ """Template profile slug for model picker reads — never per-user runtime u-*."""
12453
12784
  raw = str(profile or "").strip()
12454
12785
  if not raw:
12455
12786
  return _primary_profile()
12456
12787
  slug = safe_profile_slug(raw)
12457
12788
  if _is_runtime_profile_slug(slug):
12458
- return resolve_hermes_profile(slug)
12789
+ return resolve_validated_profile(_runtime_template_slug(slug))
12459
12790
  return resolve_validated_profile(slug)
12460
12791
 
12461
12792
 
@@ -12484,10 +12815,13 @@ def _augment_model_suggestions(
12484
12815
  extra: list[dict[str, str]] = []
12485
12816
  primary = str(block.get("default") or "").strip()
12486
12817
  if primary and primary not in seen:
12818
+ bill = _billing_provider_id_from_hermes_config(str(block.get("provider") or "")) or str(
12819
+ block.get("provider") or "openrouter"
12820
+ )
12487
12821
  extra.append(
12488
12822
  _model_suggestion_row(
12489
12823
  primary,
12490
- provider_label=str(block.get("provider") or "OpenRouter").title(),
12824
+ provider_label=_provider_display_label(bill),
12491
12825
  label="Current primary",
12492
12826
  description="Active model for this agent profile",
12493
12827
  ),
@@ -12503,10 +12837,11 @@ def _augment_model_suggestions(
12503
12837
  full = model if "/" in model else f"{prov}/{model}"
12504
12838
  if full in seen:
12505
12839
  continue
12840
+ bill = _billing_provider_id_from_hermes_config(prov) or prov
12506
12841
  extra.append(
12507
12842
  _model_suggestion_row(
12508
12843
  full,
12509
- provider_label=prov.title(),
12844
+ provider_label=_provider_display_label(bill),
12510
12845
  label=f"Fallback · {model}",
12511
12846
  description="Configured in this profile's fallback chain",
12512
12847
  ),
@@ -12550,13 +12885,14 @@ def _llm_billing_provider(
12550
12885
  prof = resolve_hermes_profile(profile)
12551
12886
  block = _read_model_block(prof)
12552
12887
  cfg_provider = str(block.get("provider") or "").strip().lower()
12553
- if not cfg_provider:
12554
- cfg_provider = str(_read_model_from_config(prof)[0] or "").strip().lower()
12888
+ billing_cfg = _billing_provider_id_from_hermes_config(cfg_provider)
12889
+ user = str(user_id or "").strip()
12890
+ ws = str(workspace_id or "").strip()
12891
+ if billing_cfg and user and _user_can_use_llm(user, ws, billing_cfg):
12892
+ return billing_cfg
12555
12893
  candidate = _billing_provider_from_block(
12556
12894
  str(block.get("base_url") or ""), cfg_provider, provider_hint,
12557
12895
  )
12558
- user = str(user_id or "").strip()
12559
- ws = str(workspace_id or "").strip()
12560
12896
  if user and not _user_can_use_llm(user, ws, candidate):
12561
12897
  connected = sorted(_user_llm_providers_for_picker(user))
12562
12898
  if connected:
@@ -12591,6 +12927,12 @@ def _reconcile_profile_llm_for_user(
12591
12927
  if not connected:
12592
12928
  return False
12593
12929
  billing = connected[0]
12930
+ if _oauth_llm_provider_spec(billing) and _user_can_use_llm(user, workspace_id, billing):
12931
+ current = str(_read_model_from_config(prof)[1] or "").strip()
12932
+ if not _resolve_billing_provider_for_model(current, {billing}):
12933
+ current = str((PROVIDER_MVP_MODELS.get(billing) or {}).get("primary") or "")
12934
+ _apply_model_for_billing_provider(prof, billing, current, user_id=user)
12935
+ return True
12594
12936
  if _user_can_use_llm(user, workspace_id, billing) and _profile_llm_proxy_matches_billing(prof, billing):
12595
12937
  return False
12596
12938
  changed = _apply_mvp_model_for_provider(prof, billing)
@@ -12637,26 +12979,9 @@ def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id:
12637
12979
  llm_spec
12638
12980
  and str(llm_spec.get("connect_mode") or "") == "oauth"
12639
12981
  and user
12640
- and _hermes_oauth_tokens_present(user, _hermes_auth_id_for_spec(llm_spec))
12982
+ and _sync_oauth_llm_to_profile(prof, user, llm_provider)
12641
12983
  ):
12642
- src_path = _user_hermes_auth_path(user)
12643
- try:
12644
- user_auth = json.loads(src_path.read_text(encoding="utf-8"))
12645
- except (OSError, json.JSONDecodeError):
12646
- user_auth = {}
12647
- hermes_id = _hermes_auth_id_for_spec(llm_spec)
12648
- providers = user_auth.get("providers") if isinstance(user_auth, dict) else None
12649
- block = providers.get(hermes_id) if isinstance(providers, dict) else None
12650
- if isinstance(block, dict):
12651
- merged = auth.get("providers") if isinstance(auth.get("providers"), dict) else {}
12652
- merged[hermes_id] = block
12653
- auth["providers"] = merged
12654
- auth["version"] = 1
12655
- auth["updated_at"] = _utc_now()
12656
- auth_path.parent.mkdir(parents=True, exist_ok=True)
12657
- auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
12658
- _publish_profile_gateway_secrets(prof)
12659
- return True
12984
+ return True
12660
12985
 
12661
12986
  if _is_runtime_profile_slug(prof):
12662
12987
  if not user or not _user_can_use_llm(user, workspace_id, llm_provider):
@@ -13513,6 +13838,8 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13513
13838
  if _triggering_user:
13514
13839
  _reconcile_profile_llm_for_user(prof, _triggering_user, _workspace_id)
13515
13840
  llm_provider = _llm_billing_provider(prof, user_id=_triggering_user, workspace_id=_workspace_id)
13841
+ model_block = _read_model_block(prof)
13842
+ model_used = str(model_block.get("default") or "").strip()
13516
13843
  llm_ready = bool(
13517
13844
  _triggering_user
13518
13845
  and _user_can_use_llm(_triggering_user, _workspace_id, llm_provider)
@@ -13591,8 +13918,10 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13591
13918
  handler.send_header("X-Workframe-Api-Port", str(lifecycle["api_port"]))
13592
13919
  handler.end_headers()
13593
13920
  handler.wfile.write(
13594
- b"event: run.started\n"
13595
- b"data: {\"text\":\"Contacting model...\"}\n\n"
13921
+ (
13922
+ "event: run.started\n"
13923
+ f"data: {json.dumps({'text': 'Contacting model...', 'model': model_used, 'llm_provider': llm_provider})}\n\n"
13924
+ ).encode("utf-8")
13596
13925
  )
13597
13926
  handler.wfile.flush()
13598
13927
  buffer = b""
@@ -14454,7 +14783,10 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
14454
14783
  },
14455
14784
  "codex": {
14456
14785
  "primary": "gpt-5.4-medium",
14457
- "fallbacks": [{"provider": "openai-codex", "model": "gpt-5.4-medium"}],
14786
+ "fallbacks": [
14787
+ {"provider": "openai-codex", "model": "gpt-5.4-medium"},
14788
+ {"provider": "openai-codex", "model": "gpt-5.4-mini"},
14789
+ ],
14458
14790
  },
14459
14791
  "nous": {
14460
14792
  "primary": "nousresearch/hermes-4-70b",
@@ -14463,6 +14795,185 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
14463
14795
  }
14464
14796
 
14465
14797
 
14798
+ # ponytail: billing id (connect catalog) ↔ Hermes config model.provider ↔ picker rows
14799
+ _PROVIDER_CATALOG_TAGS: dict[str, frozenset[str]] = {
14800
+ "codex": frozenset({"codex", "openai"}),
14801
+ "openai": frozenset({"openai"}),
14802
+ "openrouter": frozenset({"openrouter"}),
14803
+ "anthropic": frozenset({"anthropic"}),
14804
+ "google": frozenset({"google"}),
14805
+ "deepseek": frozenset({"deepseek"}),
14806
+ "nous": frozenset({"nous"}),
14807
+ }
14808
+
14809
+ _CODEX_EXTRA_MODELS: tuple[tuple[str, str, str], ...] = (
14810
+ ("gpt-5.4-medium", "GPT-5.4 Medium", "Codex default — agentic, tool-capable."),
14811
+ ("gpt-5.4-mini", "GPT-5.4 Mini", "Faster Codex model on ChatGPT account."),
14812
+ )
14813
+
14814
+
14815
+ def _billing_provider_id_from_hermes_config(cfg_provider: str) -> str:
14816
+ p = str(cfg_provider or "").strip().lower()
14817
+ if p in {"openai-codex", "openai_codex", "codex"}:
14818
+ return "codex"
14819
+ if p in PROVIDER_MVP_MODELS:
14820
+ return p
14821
+ return ""
14822
+
14823
+
14824
+ def _catalog_tags_for_billing_provider(provider_id: str) -> frozenset[str]:
14825
+ return _PROVIDER_CATALOG_TAGS.get(str(provider_id or "").strip().lower(), frozenset())
14826
+
14827
+
14828
+ def _catalog_row_for_billing_provider(row: dict[str, str], provider_id: str) -> bool:
14829
+ tags = _catalog_tags_for_billing_provider(provider_id)
14830
+ if not tags:
14831
+ return False
14832
+ row_tag = str(row.get("provider") or "").strip().lower()
14833
+ return row_tag in tags
14834
+
14835
+
14836
+ def _provider_display_label(provider_id: str) -> str:
14837
+ spec = _catalog_provider(provider_id) or {}
14838
+ return str(spec.get("label") or provider_id).strip() or provider_id
14839
+
14840
+
14841
+ def _model_catalog_rows_for_provider(provider_id: str) -> list[dict[str, str]]:
14842
+ """Curated models a connected billing provider can run."""
14843
+ provider_key = str(provider_id or "").strip().lower()
14844
+ label = _provider_display_label(provider_key)
14845
+ rows: list[dict[str, str]] = []
14846
+ seen: set[str] = set()
14847
+
14848
+ def _add(model: str, row_label: str, description: str) -> None:
14849
+ mid = str(model or "").strip()
14850
+ if not mid or mid in seen:
14851
+ return
14852
+ seen.add(mid)
14853
+ rows.append(
14854
+ {
14855
+ "provider": label,
14856
+ "billing_provider": provider_key,
14857
+ "model": mid,
14858
+ "label": row_label,
14859
+ "description": description,
14860
+ }
14861
+ )
14862
+
14863
+ if provider_key == "codex":
14864
+ for model, row_label, description in _CODEX_EXTRA_MODELS:
14865
+ _add(model, row_label, description)
14866
+ mvp = PROVIDER_MVP_MODELS.get(provider_key) or {}
14867
+ primary = str(mvp.get("primary") or "").strip()
14868
+ if primary:
14869
+ _add(primary, primary, f"Default for {label}.")
14870
+ for fb in mvp.get("fallbacks") or []:
14871
+ if isinstance(fb, dict):
14872
+ _add(str(fb.get("model") or ""), str(fb.get("model") or ""), f"Fallback for {label}.")
14873
+ for row in HERMES_MODEL_CATALOG:
14874
+ if _catalog_row_for_billing_provider(row, provider_key):
14875
+ _add(
14876
+ str(row.get("model") or ""),
14877
+ str(row.get("label") or row.get("model") or ""),
14878
+ str(row.get("description") or ""),
14879
+ )
14880
+ return rows
14881
+
14882
+
14883
+ def _suggestions_for_connected_llm_providers(connected: set[str]) -> list[dict[str, str]]:
14884
+ out: list[dict[str, str]] = []
14885
+ seen: set[str] = set()
14886
+ for provider_id in sorted(connected):
14887
+ for row in _model_catalog_rows_for_provider(provider_id):
14888
+ mid = str(row.get("model") or "").strip()
14889
+ if not mid or mid in seen:
14890
+ continue
14891
+ seen.add(mid)
14892
+ out.append(row)
14893
+ return out
14894
+
14895
+
14896
+ def _resolve_billing_provider_for_model(model_id: str, connected: set[str]) -> str:
14897
+ mid = str(model_id or "").strip()
14898
+ if not mid or not connected:
14899
+ return ""
14900
+ for provider_id in sorted(connected):
14901
+ for row in _model_catalog_rows_for_provider(provider_id):
14902
+ if str(row.get("model") or "").strip() == mid:
14903
+ return provider_id
14904
+ low = mid.lower()
14905
+ if low.startswith("gpt-") and "codex" in connected:
14906
+ return "codex"
14907
+ if low.startswith("gpt-") and "openai" in connected:
14908
+ return "openai"
14909
+ if "/" in mid and "openrouter" in connected:
14910
+ return "openrouter"
14911
+ if low.startswith("claude") and "anthropic" in connected:
14912
+ return "anthropic"
14913
+ if "gemini" in low and "google" in connected:
14914
+ return "google"
14915
+ if "deepseek" in low and "deepseek" in connected:
14916
+ return "deepseek"
14917
+ return ""
14918
+
14919
+
14920
+ def _strip_profile_model_proxy_fields(profile: str) -> None:
14921
+ """OAuth/native providers: drop internal proxy base_url + lease api_key from model block."""
14922
+ config_path = _profile_gateway_config_path(profile)
14923
+ if not config_path or not config_path.is_file():
14924
+ return
14925
+ try:
14926
+ lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
14927
+ except OSError:
14928
+ return
14929
+ out: list[str] = []
14930
+ in_model = False
14931
+ for line in lines:
14932
+ stripped = line.lstrip()
14933
+ if stripped.startswith("model:") and not line.startswith((" ", "\t")):
14934
+ in_model = True
14935
+ out.append(line)
14936
+ continue
14937
+ if in_model and stripped and not line.startswith((" ", "\t", "#")):
14938
+ in_model = False
14939
+ if in_model and stripped.startswith(("base_url:", "api_key:")):
14940
+ continue
14941
+ out.append(line)
14942
+ try:
14943
+ config_path.write_text("".join(out), encoding="utf-8")
14944
+ except OSError:
14945
+ return
14946
+
14947
+
14948
+ def _apply_model_for_billing_provider(
14949
+ profile: str,
14950
+ billing: str,
14951
+ model_id: str,
14952
+ user_id: str = "",
14953
+ ) -> bool:
14954
+ billing = str(billing or "").strip().lower()
14955
+ ok, _ = _set_profile_model(profile, model_id)
14956
+ if not ok:
14957
+ return False
14958
+ if _oauth_llm_provider_spec(billing):
14959
+ ok_provider, _ = _set_profile_model_provider(profile, _hermes_config_provider_id(billing))
14960
+ if not ok_provider:
14961
+ return False
14962
+ _strip_profile_model_proxy_fields(profile)
14963
+ fallbacks = PROVIDER_MVP_MODELS.get(billing, {}).get("fallbacks") or []
14964
+ if isinstance(fallbacks, list):
14965
+ _write_fallback_chain(profile, fallbacks)
14966
+ user = str(user_id or "").strip()
14967
+ if user:
14968
+ _sync_oauth_llm_to_profile(profile, user, billing)
14969
+ return True
14970
+ ok_provider, _ = _set_profile_model_provider(profile, "custom")
14971
+ if not ok_provider:
14972
+ return False
14973
+ _set_profile_model_base_url(profile, _llm_proxy_base_url(billing))
14974
+ return True
14975
+
14976
+
14466
14977
  def _hermes_config_provider_id(provider_key: str) -> str:
14467
14978
  spec = _catalog_provider(provider_key)
14468
14979
  if spec and spec.get("hermes_auth_id"):
@@ -14515,19 +15026,25 @@ def _bootstrap_model_after_llm_connect(
14515
15026
  ws = str(workspace_id or "").strip()
14516
15027
  primary = _primary_profile()
14517
15028
  if primary:
14518
- _, model = _read_model_from_config(primary)
14519
- if not str(model or "").strip():
14520
- _apply_mvp_model_for_provider(primary, provider_key)
14521
- elif user:
15029
+ if user and _oauth_llm_provider_spec(provider_key):
14522
15030
  _reconcile_profile_llm_for_user(primary, user, ws, prefer_provider=provider_key)
15031
+ else:
15032
+ _, model = _read_model_from_config(primary)
15033
+ if not str(model or "").strip():
15034
+ _apply_mvp_model_for_provider(primary, provider_key)
15035
+ elif user:
15036
+ _reconcile_profile_llm_for_user(primary, user, ws, prefer_provider=provider_key)
14523
15037
  template = primary or "workframe-agent"
14524
15038
  runtime = _runtime_profile_slug(user_id, template)
14525
15039
  if _runtime_profile_on_disk(runtime):
14526
- _, model = _read_model_from_config(runtime)
14527
- if not str(model or "").strip():
14528
- _apply_mvp_model_for_provider(runtime, provider_key)
14529
- elif user:
15040
+ if user and _oauth_llm_provider_spec(provider_key):
14530
15041
  _reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
15042
+ else:
15043
+ _, model = _read_model_from_config(runtime)
15044
+ if not str(model or "").strip():
15045
+ _apply_mvp_model_for_provider(runtime, provider_key)
15046
+ elif user:
15047
+ _reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
14531
15048
 
14532
15049
 
14533
15050
  # Curated snapshot of commonly-used models per provider. Surfaced in the
@@ -14558,6 +15075,12 @@ HERMES_MODEL_CATALOG: list[dict[str, str]] = [
14558
15075
  "label": "o1", "description": "Reasoning model."},
14559
15076
  {"provider": "OpenAI", "model": "o1-mini",
14560
15077
  "label": "o1 mini", "description": "Cheaper reasoning."},
15078
+ {"provider": "OpenAI", "model": "gpt-5.4-mini",
15079
+ "label": "GPT-5.4 Mini", "description": "Faster Codex / OpenAI model."},
15080
+ {"provider": "Codex", "model": "gpt-5.4-medium",
15081
+ "label": "GPT-5.4 Medium (Codex)", "description": "Codex ChatGPT account default."},
15082
+ {"provider": "Codex", "model": "gpt-5.4-mini",
15083
+ "label": "GPT-5.4 Mini (Codex)", "description": "Faster Codex model."},
14561
15084
  {"provider": "Google", "model": "gemini-2.5-pro",
14562
15085
  "label": "Gemini 2.5 Pro",
14563
15086
  "description": "Long context, strong reasoning."},
@@ -14709,7 +15232,7 @@ def _set_profile_model_provider(profile: str, provider: str) -> tuple[bool, str]
14709
15232
  return True, ""
14710
15233
 
14711
15234
 
14712
- def _read_model_block(profile: str) -> dict[str, Any]:
15235
+ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
14713
15236
  """Parse the model surface from the profile's config.yaml:
14714
15237
  `model.default`, `model.provider`, etc. (siblings of `model:`) AND
14715
15238
  the top-level `fallback_providers:` block. Pure text scan — no
@@ -14818,6 +15341,48 @@ def _read_model_block(profile: str) -> dict[str, Any]:
14818
15341
  return out
14819
15342
 
14820
15343
 
15344
+ def _read_model_block(profile: str) -> dict[str, Any]:
15345
+ """Model surface for a profile. Runtime u-* slugs inherit default/chain from template."""
15346
+ raw = str(profile or "").strip()
15347
+ if not raw:
15348
+ return _parse_model_block_from_disk("")
15349
+ try:
15350
+ prof = resolve_hermes_profile(raw)
15351
+ except ValueError:
15352
+ return _parse_model_block_from_disk(raw)
15353
+ block = _parse_model_block_from_disk(prof)
15354
+ if not _is_runtime_profile_slug(prof):
15355
+ return block
15356
+ template = resolve_validated_profile(_runtime_template_slug(prof))
15357
+ tblock = _parse_model_block_from_disk(template)
15358
+ if str(tblock.get("default") or "").strip():
15359
+ block["default"] = tblock["default"]
15360
+ if tblock.get("fallback_chain"):
15361
+ block["fallback_chain"] = list(tblock["fallback_chain"])
15362
+ tpl_provider = str(tblock.get("provider") or "").strip()
15363
+ if tpl_provider:
15364
+ block["provider"] = tpl_provider
15365
+ return block
15366
+
15367
+
15368
+ def _sync_runtime_model_from_template(runtime: str, template: str) -> None:
15369
+ """Keep per-user runtime Hermes config aligned with agent template model picks."""
15370
+ runtime_slug = safe_profile_slug(runtime)
15371
+ template_slug = resolve_validated_profile(template)
15372
+ if not _is_runtime_profile_slug(runtime_slug) or not profile_exists(runtime_slug):
15373
+ return
15374
+ tblock = _parse_model_block_from_disk(template_slug)
15375
+ default = str(tblock.get("default") or "").strip()
15376
+ if default:
15377
+ _set_profile_model(runtime_slug, default)
15378
+ chain = [e for e in (tblock.get("fallback_chain") or []) if isinstance(e, dict)]
15379
+ if chain:
15380
+ _write_fallback_chain(runtime_slug, chain)
15381
+ billing = _billing_provider_id_from_hermes_config(str(tblock.get("provider") or ""))
15382
+ if billing and default:
15383
+ _apply_model_for_billing_provider(runtime_slug, billing, default)
15384
+
15385
+
14821
15386
  def _write_fallback_chain(profile: str, chain: list[dict[str, str]]) -> tuple[bool, str]:
14822
15387
  """Rewrite the `fallback_providers` block in config.yaml to match the
14823
15388
  supplied chain. Preserves all other keys, comments, and ordering.
@@ -14919,49 +15484,79 @@ def _write_fallback_chain(profile: str, chain: list[dict[str, str]]) -> tuple[bo
14919
15484
  return True, ""
14920
15485
 
14921
15486
 
14922
- def hermes_models(profile: str = "", user_id: str = "", workspace_id: str = "") -> dict[str, Any]:
15487
+ def hermes_models(
15488
+ profile: str = "",
15489
+ user_id: str = "",
15490
+ workspace_id: str = "",
15491
+ *,
15492
+ selection_only: bool = False,
15493
+ ) -> dict[str, Any]:
14923
15494
  """Return a profile's model surface: primary, fallback chain,
14924
15495
  and curated suggestions scoped to the active provider. The catalog
14925
15496
  is a hint list, not a hardcoded menu — the BFF accepts any
14926
15497
  `provider/model` id and the picker always lets the user type a
14927
15498
  custom one.
14928
15499
  """
14929
- primary_profile = _resolve_models_profile(profile) if profile else _primary_profile()
14930
- if primary_profile:
14931
- _bootstrap_profile_providers(primary_profile, user_id, workspace_id)
14932
- block = _read_model_block(primary_profile) if primary_profile else {
14933
- "default": "", "provider": "", "base_url": "",
14934
- "providers": {}, "fallback_chain": [],
14935
- }
14936
- active_provider = block.get("provider", "").strip()
14937
15500
  picker_llm = _user_llm_providers_for_picker(user_id) if user_id else set()
14938
15501
  connected = _connected_provider_names(user_id, workspace_id)
14939
- has_llm = bool(picker_llm) if user_id else any(
15502
+ has_llm = _user_llm_has_provider(user_id, workspace_id) if user_id else any(
14940
15503
  str(spec.get("category") or "") == "llm"
14941
15504
  and str(spec.get("id")).lower() in {n.lower() for n in connected}
14942
15505
  for spec in PROVIDER_CONNECT_CATALOG
14943
15506
  )
14944
- def _provider_matches(row_provider: str) -> bool:
14945
- llm_set = picker_llm if user_id else {n.lower() for n in connected}
14946
- if not llm_set:
14947
- return False
14948
- row_key = str(row_provider or "").strip().lower().replace(" ", "")
14949
- active_key = active_provider.lower().replace(" ", "")
14950
- if active_key and (row_key == active_key or active_key in row_key or row_key in active_key):
14951
- return True
14952
- return any(
14953
- row_key == name or name in row_key or row_key in name
14954
- for name in llm_set
15507
+ connected_llm = picker_llm if user_id else {
15508
+ str(spec["id"]).lower()
15509
+ for spec in PROVIDER_CONNECT_CATALOG
15510
+ if str(spec.get("category") or "") == "llm"
15511
+ and str(spec.get("id")).lower() in {n.lower() for n in connected}
15512
+ }
15513
+
15514
+ if selection_only:
15515
+ user_primary, user_chain = _read_user_llm_prefs(user_id) if user_id else ("", [])
15516
+ primary = user_primary or (HERMES_DEFAULT_PRIMARY if has_llm else "")
15517
+ billing = _resolve_billing_provider_for_model(primary, connected_llm) if primary else ""
15518
+ suggestions = _augment_model_suggestions(
15519
+ _suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
15520
+ {
15521
+ "default": primary,
15522
+ "provider": billing or "",
15523
+ "base_url": "",
15524
+ "providers": {},
15525
+ "fallback_chain": user_chain,
15526
+ },
14955
15527
  )
15528
+ return {
15529
+ "ok": True,
15530
+ "profile": "",
15531
+ "primary": primary if has_llm else "",
15532
+ "provider": billing if has_llm else "",
15533
+ "base_url": "",
15534
+ "fallback_chain": user_chain if has_llm else [],
15535
+ "suggestions": suggestions,
15536
+ "connected_providers": sorted(picker_llm if user_id else connected),
15537
+ "has_llm_provider": has_llm,
15538
+ "billing_provider": billing if has_llm else "",
15539
+ "selection_only": True,
15540
+ "default_primary": HERMES_DEFAULT_PRIMARY,
15541
+ "default_fallback_chain": HERMES_DEFAULT_FALLBACK_CHAIN,
15542
+ }
14956
15543
 
15544
+ try:
15545
+ primary_profile = _resolve_models_profile(profile) if profile else _primary_profile()
15546
+ except ValueError as exc:
15547
+ return {"ok": False, "error": str(exc)}
15548
+ if primary_profile:
15549
+ _bootstrap_profile_providers(primary_profile, user_id, workspace_id)
15550
+ block = _read_model_block(primary_profile) if primary_profile else {
15551
+ "default": "", "provider": "", "base_url": "",
15552
+ "providers": {}, "fallback_chain": [],
15553
+ }
15554
+ active_provider = block.get("provider", "").strip()
14957
15555
  suggestions = _augment_model_suggestions(
14958
- [
14959
- row for row in HERMES_MODEL_CATALOG
14960
- if _provider_matches(str(row.get("provider") or ""))
14961
- ] if (picker_llm or connected) else [],
15556
+ _suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
14962
15557
  block,
14963
15558
  )
14964
- if user_id and "openrouter" in {n.lower() for n in (picker_llm or connected)}:
15559
+ if user_id and "openrouter" in connected_llm:
14965
15560
  resolved = _resolve_credential(user_id, workspace_id, "openrouter", user_only=True)
14966
15561
  secret = _credential_secret(resolved or {}, user_id) if resolved else ""
14967
15562
  live = openrouter_catalog.live_suggestions(secret, limit=30)
@@ -14981,16 +15576,25 @@ def hermes_models(profile: str = "", user_id: str = "", workspace_id: str = "")
14981
15576
  seen_models.add(mid)
14982
15577
  deduped.append(row)
14983
15578
  suggestions = deduped
15579
+ primary_model = str(block.get("default") or "").strip()
15580
+ billing = _billing_provider_id_from_hermes_config(active_provider)
15581
+ if not billing and primary_model:
15582
+ billing = _resolve_billing_provider_for_model(primary_model, connected_llm) or ""
15583
+ if str(active_provider or "").lower() == "custom" and billing:
15584
+ display_provider = billing
15585
+ else:
15586
+ display_provider = active_provider
14984
15587
  return {
14985
15588
  "ok": True,
14986
15589
  "profile": primary_profile,
14987
- "primary": block.get("default", "") if has_llm else "",
14988
- "provider": active_provider if has_llm else "",
15590
+ "primary": primary_model if has_llm else "",
15591
+ "provider": display_provider if has_llm else "",
14989
15592
  "base_url": block.get("base_url", ""),
14990
15593
  "fallback_chain": block.get("fallback_chain", []) if has_llm else [],
14991
15594
  "suggestions": suggestions,
14992
15595
  "connected_providers": sorted(picker_llm if user_id else connected),
14993
15596
  "has_llm_provider": has_llm,
15597
+ "billing_provider": billing if has_llm else "",
14994
15598
  "default_primary": HERMES_DEFAULT_PRIMARY,
14995
15599
  "default_fallback_chain": HERMES_DEFAULT_FALLBACK_CHAIN,
14996
15600
  }
@@ -15020,30 +15624,76 @@ def hermes_apply_default_model_config() -> dict[str, Any]:
15020
15624
  }
15021
15625
 
15022
15626
 
15023
- def hermes_model_set(profile: str, model_id: str) -> dict[str, Any]:
15024
- """Set the primary model for a profile. Falls back to the native
15025
- one if not supplied. The change writes `model.default` only — the
15026
- fallback chain is preserved.
15027
- """
15627
+ def hermes_model_set(
15628
+ profile: str,
15629
+ model_id: str,
15630
+ user_id: str = "",
15631
+ workspace_id: str = "",
15632
+ *,
15633
+ selection_only: bool = False,
15634
+ ) -> dict[str, Any]:
15635
+ """Set primary model and align Hermes provider routing to a connected billing provider."""
15636
+ user = str(user_id or "").strip()
15637
+ if selection_only:
15638
+ if not user:
15639
+ return {"ok": False, "error": "unauthorized"}
15640
+ connected = _user_llm_providers_for_picker(user)
15641
+ billing = _resolve_billing_provider_for_model(model_id, connected)
15642
+ if not billing and not _user_llm_has_provider(user, workspace_id):
15643
+ return {"ok": False, "error": "connect an LLM provider first"}
15644
+ _write_user_llm_prefs(user, primary=model_id)
15645
+ return {
15646
+ "ok": True,
15647
+ "profile": "",
15648
+ "model": model_id,
15649
+ "provider": billing or "",
15650
+ "billing_provider": billing or "",
15651
+ "selection_only": True,
15652
+ }
15028
15653
  target = profile or _primary_profile()
15029
15654
  if not target:
15030
15655
  return {"ok": False, "error": "no profile resolved"}
15031
15656
  if not model_id or " " in model_id or "\n" in model_id:
15032
15657
  return {"ok": False, "error": "invalid model id"}
15033
- ok, err = _set_profile_model(target, model_id)
15034
- if not ok:
15035
- return {"ok": False, "error": err}
15036
- return {"ok": True, "profile": target, "model": model_id}
15658
+ user = str(user_id or "").strip()
15659
+ ws = str(workspace_id or "").strip()
15660
+ connected = _user_llm_providers_for_picker(user) if user else set()
15661
+ billing = _resolve_billing_provider_for_model(model_id, connected)
15662
+ if billing:
15663
+ if not _apply_model_for_billing_provider(target, billing, model_id, user_id=user):
15664
+ return {"ok": False, "error": "model apply failed"}
15665
+ else:
15666
+ ok, err = _set_profile_model(target, model_id)
15667
+ if not ok:
15668
+ return {"ok": False, "error": err}
15669
+ tpl = safe_profile_slug(target)
15670
+ if user and not _is_runtime_profile_slug(tpl):
15671
+ runtime = _runtime_profile_slug(user, tpl)
15672
+ if _runtime_profile_on_disk(runtime):
15673
+ _sync_runtime_model_from_template(runtime, tpl)
15674
+ block = _read_model_block(target)
15675
+ return {
15676
+ "ok": True,
15677
+ "profile": target,
15678
+ "model": model_id,
15679
+ "provider": str(block.get("provider") or ""),
15680
+ "billing_provider": billing or _billing_provider_id_from_hermes_config(
15681
+ str(block.get("provider") or "")
15682
+ ),
15683
+ }
15037
15684
 
15038
15685
 
15039
- def hermes_fallback_chain_set(profile: str, chain: list[Any]) -> dict[str, Any]:
15686
+ def hermes_fallback_chain_set(
15687
+ profile: str,
15688
+ chain: list[Any],
15689
+ *,
15690
+ selection_only: bool = False,
15691
+ user_id: str = "",
15692
+ ) -> dict[str, Any]:
15040
15693
  """Set the fallback chain for a profile. Each entry must have
15041
15694
  `provider` and `model` keys. Order matters: Hermes tries them in
15042
15695
  sequence when the primary fails.
15043
15696
  """
15044
- target = profile or _primary_profile()
15045
- if not target:
15046
- return {"ok": False, "error": "no profile resolved"}
15047
15697
  normalized: list[dict[str, str]] = []
15048
15698
  for entry in chain:
15049
15699
  if not isinstance(entry, dict):
@@ -15056,9 +15706,24 @@ def hermes_fallback_chain_set(profile: str, chain: list[Any]) -> dict[str, Any]:
15056
15706
  "error": f"invalid fallback entry: provider={provider!r} model={model!r}",
15057
15707
  }
15058
15708
  normalized.append({"provider": provider, "model": model})
15709
+ if selection_only:
15710
+ user = str(user_id or "").strip()
15711
+ if not user:
15712
+ return {"ok": False, "error": "unauthorized"}
15713
+ _write_user_llm_prefs(user, fallback_chain=normalized)
15714
+ return {"ok": True, "profile": "", "fallback_chain": normalized, "selection_only": True}
15715
+ target = profile or _primary_profile()
15716
+ if not target:
15717
+ return {"ok": False, "error": "no profile resolved"}
15059
15718
  ok, err = _write_fallback_chain(target, normalized)
15060
15719
  if not ok:
15061
15720
  return {"ok": False, "error": err}
15721
+ user = str(user_id or "").strip()
15722
+ tpl = safe_profile_slug(target)
15723
+ if user and not _is_runtime_profile_slug(tpl):
15724
+ runtime = _runtime_profile_slug(user, tpl)
15725
+ if _runtime_profile_on_disk(runtime):
15726
+ _sync_runtime_model_from_template(runtime, tpl)
15062
15727
  return {"ok": True, "profile": target, "fallback_chain": normalized}
15063
15728
 
15064
15729
 
@@ -15844,6 +16509,11 @@ class Handler(BaseHTTPRequestHandler):
15844
16509
  except Exception:
15845
16510
  return self._json(200, {"setup_complete": False})
15846
16511
  if path == "/api/install/status":
16512
+ if install_api.install_window_open(str(_workframe_db_path())):
16513
+ try:
16514
+ _ensure_native_hermes_profile()
16515
+ except Exception:
16516
+ pass
15847
16517
  payload = install_api.install_status_payload(
15848
16518
  DEPLOYMENT_MODE,
15849
16519
  SECURE_MODE,
@@ -16531,7 +17201,10 @@ class Handler(BaseHTTPRequestHandler):
16531
17201
  profile = qs.get("profile", [""])[0]
16532
17202
  user_id = str(getattr(self, "auth_user", "") or "")
16533
17203
  workspace_id = qs.get("workspace_id", [""])[0]
16534
- return self._json(200, hermes_models(profile, user_id, workspace_id))
17204
+ selection_only = qs.get("selection_only", ["0"])[0] in {"1", "true", "yes"}
17205
+ payload = hermes_models(profile, user_id, workspace_id, selection_only=selection_only)
17206
+ status = 200 if payload.get("ok", True) else 400
17207
+ return self._json(status, payload)
16535
17208
  if path == "/api/hermes/profiles/status":
16536
17209
  profile = resolve_validated_profile(qs.get("profile", [""])[0] or _primary_profile())
16537
17210
  if SUPERVISOR_URL:
@@ -16847,6 +17520,8 @@ class Handler(BaseHTTPRequestHandler):
16847
17520
 
16848
17521
  def _complete_install(self, user_id: str, body: dict[str, Any]) -> dict[str, Any]:
16849
17522
  global DEPLOYMENT_MODE
17523
+ if not _native_profile_present():
17524
+ _ensure_native_hermes_profile()
16850
17525
  data = body if isinstance(body, dict) else {}
16851
17526
  if not user_id and DEPLOYMENT_MODE == "single_user_local":
16852
17527
  user_id = str(data.get("user_id") or secrets.token_hex(8))
@@ -18532,11 +19207,24 @@ class Handler(BaseHTTPRequestHandler):
18532
19207
  profile = str(body.get("profile", "")).strip()
18533
19208
  user_id = str(getattr(self, "auth_user", "") or "")
18534
19209
  workspace_id = str(body.get("workspace_id", "")).strip()
19210
+ selection_only = bool(body.get("selection_only"))
18535
19211
  if not model_id:
18536
19212
  return self._json(400, {"error": "model required"})
18537
- if profile:
18538
- _bootstrap_profile_providers(resolve_validated_profile(profile), user_id, workspace_id)
18539
- return self._json(200, hermes_model_set(profile, model_id))
19213
+ if profile and not selection_only:
19214
+ try:
19215
+ _bootstrap_profile_providers(_resolve_models_profile(profile), user_id, workspace_id)
19216
+ except ValueError as exc:
19217
+ return self._json(400, {"ok": False, "error": str(exc)})
19218
+ return self._json(
19219
+ 200,
19220
+ hermes_model_set(
19221
+ profile,
19222
+ model_id,
19223
+ user_id,
19224
+ workspace_id,
19225
+ selection_only=selection_only,
19226
+ ),
19227
+ )
18540
19228
  if path == "/api/hermes/model/apply-default":
18541
19229
  if not _check_auth(self):
18542
19230
  return self._json(401, {"error": "unauthorized"})
@@ -18546,13 +19234,23 @@ class Handler(BaseHTTPRequestHandler):
18546
19234
  if path == "/api/hermes/fallback-chain":
18547
19235
  if not _check_auth(self):
18548
19236
  return self._json(401, {"error": "unauthorized"})
18549
- if not _role_allows(self, OWNER_ADMIN_ROLES):
18550
- return self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
18551
19237
  profile = str(body.get("profile", "")).strip()
18552
19238
  chain = body.get("chain", [])
19239
+ selection_only = bool(body.get("selection_only"))
19240
+ user_id = str(getattr(self, "auth_user", "") or "")
18553
19241
  if not isinstance(chain, list):
18554
19242
  return self._json(400, {"error": "chain must be a list"})
18555
- return self._json(200, hermes_fallback_chain_set(profile, chain))
19243
+ if not selection_only and not _role_allows(self, OWNER_ADMIN_ROLES):
19244
+ return self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
19245
+ return self._json(
19246
+ 200,
19247
+ hermes_fallback_chain_set(
19248
+ profile,
19249
+ chain,
19250
+ selection_only=selection_only,
19251
+ user_id=user_id,
19252
+ ),
19253
+ )
18556
19254
  if path == "/api/board":
18557
19255
  if not _check_auth(self):
18558
19256
  return self._json(401, {"error": "unauthorized"})