create-workframe 0.1.6 → 0.1.7

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 (87) hide show
  1. package/package.json +1 -1
  2. package/workframe-api/package.json +1 -1
  3. package/workframe-api/server.py +540 -111
  4. package/workframe-api/stack_config.py +11 -3
  5. package/workframe-api/test_ensure_native_profile.py +29 -0
  6. package/workframe-api/test_oauth_llm.py +58 -0
  7. package/workframe-api/test_provider_model_surface.py +31 -0
  8. package/workframe-api/test_stack_config_install_smtp.py +51 -0
  9. package/workframe-ui/public/assets/{arc-DoYr2T5c.js → arc-Cn-lt9fT.js} +1 -1
  10. package/workframe-ui/public/assets/architecture-7EHR7CIX-CCMNXHod.js +1 -0
  11. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-Clher08G.js → architectureDiagram-3BPJPVTR-qXoGCqfH.js} +1 -1
  12. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bdw9txrZ.js → blockDiagram-GPEHLZMM-DoopFlWj.js} +1 -1
  13. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DlI_h_uo.js → c4Diagram-AAUBKEIU-BRa7hfzE.js} +1 -1
  14. package/workframe-ui/public/assets/channel-CtFV3sE3.js +1 -0
  15. package/workframe-ui/public/assets/{chunk-2J33WTMH-ol-uwoeq.js → chunk-2J33WTMH-Cl33E2hh.js} +1 -1
  16. package/workframe-ui/public/assets/{chunk-3OPIFGDE-7eDhBen0.js → chunk-3OPIFGDE-BRywYZQO.js} +1 -1
  17. package/workframe-ui/public/assets/{chunk-4BX2VUAB-xPORD7Js.js → chunk-4BX2VUAB-CI3xzp-0.js} +1 -1
  18. package/workframe-ui/public/assets/{chunk-55IACEB6-Cwd7dr1z.js → chunk-55IACEB6-Bkf3Ye3F.js} +1 -1
  19. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Ckt4Sjy1.js → chunk-5ZQYHXKU-C5RN3hUT.js} +1 -1
  20. package/workframe-ui/public/assets/{chunk-727SXJPM-sMk1op_G.js → chunk-727SXJPM-BaY0yDwt.js} +1 -1
  21. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CMgSvr6H.js → chunk-AQP2D5EJ-VMMBaDqd.js} +1 -1
  22. package/workframe-ui/public/assets/{chunk-BSJP7CBP-Dx7GN0T4.js → chunk-BSJP7CBP-DlNZ1UV3.js} +1 -1
  23. package/workframe-ui/public/assets/{chunk-CSCIHK7Q--OCu86u0.js → chunk-CSCIHK7Q-tT2HyVy0.js} +1 -1
  24. package/workframe-ui/public/assets/{chunk-FMBD7UC4-CejmwJpz.js → chunk-FMBD7UC4-CyACRaUT.js} +1 -1
  25. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CvOj2Yvp.js → chunk-KSCS5N6A-seK5WAZs.js} +1 -1
  26. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-B0nGBjLU.js → chunk-L5ZTLDWV-r5cyG5AH.js} +1 -1
  27. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Bdzvu227.js → chunk-LZXEDZCA-SnbeVxsJ.js} +2 -2
  28. package/workframe-ui/public/assets/{chunk-ND2GUHAM-CGR07jTY.js → chunk-ND2GUHAM-zbFgEXSJ.js} +1 -1
  29. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CBojDPNC.js → chunk-NZK2D7GU-UhZc8kOk.js} +1 -1
  30. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DBin5MPF.js → chunk-O5CBEL6O-DHvu2iZ-.js} +1 -1
  31. package/workframe-ui/public/assets/chunk-QZHKN3VN-B1g7l1_B.js +1 -0
  32. package/workframe-ui/public/assets/chunk-WU5MYG2G-B_qfZ6QP.js +1 -0
  33. package/workframe-ui/public/assets/{chunk-XPW4576I-TD7I7pcb.js → chunk-XPW4576I-CCPNKbzG.js} +1 -1
  34. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CJoeCNq6.js +1 -0
  35. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CJoeCNq6.js +1 -0
  36. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bmm5x5xm.js → cose-bilkent-S5V4N54A-D1CLe6Y0.js} +1 -1
  37. package/workframe-ui/public/assets/{dagre-BM42HDAG-zU2r0aq2.js → dagre-BM42HDAG-DYHcGVs_.js} +1 -1
  38. package/workframe-ui/public/assets/{diagram-2AECGRRQ-AL01qukm.js → diagram-2AECGRRQ-DVUkJ-II.js} +1 -1
  39. package/workframe-ui/public/assets/{diagram-5GNKFQAL-DKi1xSMg.js → diagram-5GNKFQAL-C9Y1Acc2.js} +1 -1
  40. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQJDQ-f-.js → diagram-KO2AKTUF-Db3zez-n.js} +1 -1
  41. package/workframe-ui/public/assets/{diagram-LMA3HP47-B1oisGUj.js → diagram-LMA3HP47-BaOR5sIr.js} +1 -1
  42. package/workframe-ui/public/assets/{diagram-OG6HWLK6-DjpJKffk.js → diagram-OG6HWLK6-fLBzMY5W.js} +1 -1
  43. package/workframe-ui/public/assets/{dist-Jk20AnQG.js → dist-uZYeD3o1.js} +1 -1
  44. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Vu8RtLUf.js → erDiagram-TEJ5UH35-BoCxDSyE.js} +1 -1
  45. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bw910jho.js +1 -0
  46. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CHAXEhbR.js → flowDiagram-I6XJVG4X-BnYa3CAj.js} +1 -1
  47. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DhiEkfH8.js → ganttDiagram-6RSMTGT7-Dm_e_Kaa.js} +1 -1
  48. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-Cuq44XvU.js → gitGraph-WXDBUCRP-CBAj8ntv.js} +1 -1
  49. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-B7kgReRt.js → gitGraphDiagram-PVQCEYII-D5auJjJh.js} +1 -1
  50. package/workframe-ui/public/assets/{index-72-j0sA5.js → index-DHLQNAXd.js} +4 -4
  51. package/workframe-ui/public/assets/{info-J43DQDTF-oV4b3M4f.js → info-J43DQDTF-BNMADxE2.js} +1 -1
  52. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CthN4b6o.js → infoDiagram-5YYISTIA-C8c45T4J.js} +1 -1
  53. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-oEGS3TKg.js → ishikawaDiagram-YF4QCWOH-CyWuHB4e.js} +1 -1
  54. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CXJsWmKF.js → journeyDiagram-JHISSGLW-B43pCuDT.js} +1 -1
  55. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DTPo8yHW.js → kanban-definition-UN3LZRKU-Dyn480BI.js} +1 -1
  56. package/workframe-ui/public/assets/{line-Bm9Pmcnz.js → line-CNtIbnmt.js} +1 -1
  57. package/workframe-ui/public/assets/{linear-Cn-uD6eX.js → linear-pmSg_4uV.js} +1 -1
  58. package/workframe-ui/public/assets/{mermaid-parser.core-BBVSTQZ4.js → mermaid-parser.core-BX4tnojE.js} +2 -2
  59. package/workframe-ui/public/assets/{mermaid.core-DE668nUy.js → mermaid.core-BtpBva5o.js} +3 -3
  60. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-4qVsiwCf.js → mindmap-definition-RKZ34NQL-DcMcB_69.js} +1 -1
  61. package/workframe-ui/public/assets/{packet-YPE3B663-CqJeoiFG.js → packet-YPE3B663-KSzK9WlD.js} +1 -1
  62. package/workframe-ui/public/assets/{pie-LRSECV5Y-DcOqVssg.js → pie-LRSECV5Y-DLOH6Mg8.js} +1 -1
  63. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CosMnpAM.js → pieDiagram-4H26LBE5-BhrX2aGk.js} +1 -1
  64. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-DtsnIK98.js → quadrantDiagram-W4KKPZXB-B5dDjbTX.js} +1 -1
  65. package/workframe-ui/public/assets/{radar-GUYGQ44K-BqLpJuWc.js → radar-GUYGQ44K-BPdjqZiu.js} +1 -1
  66. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-Pb7cuX8_.js → requirementDiagram-4Y6WPE33-21Dq5e0N.js} +1 -1
  67. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BJBlMM1m.js → sankeyDiagram-5OEKKPKP-CiA99dbO.js} +1 -1
  68. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-i4HnjP39.js → sequenceDiagram-3UESZ5HK-Bwpui7_x.js} +1 -1
  69. package/workframe-ui/public/assets/{src-DIhmc_XG.js → src-B3Axsi1U.js} +1 -1
  70. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-Ba2iClPT.js → stateDiagram-AJRCARHV-C76qwtE8.js} +1 -1
  71. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-Dn0CDWJD.js +1 -0
  72. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-CL4X9vSF.js → timeline-definition-PNZ67QCA-CufInOyk.js} +1 -1
  73. package/workframe-ui/public/assets/{treeView-BLDUP644-BJrjnbMj.js → treeView-BLDUP644-C4sxh0R-.js} +1 -1
  74. package/workframe-ui/public/assets/{treemap-LRROVOQU-CN6H54RF.js → treemap-LRROVOQU-BjV72ANV.js} +1 -1
  75. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-Cee6sbUy.js → vennDiagram-CIIHVFJN-DgsdR0Rl.js} +1 -1
  76. package/workframe-ui/public/assets/{wardley-L42UT6IY-D0cXq60c.js → wardley-L42UT6IY-DZ279G_t.js} +1 -1
  77. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CaH9XkCx.js → wardleyDiagram-YWT4CUSO-Bde25kvz.js} +1 -1
  78. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BEisgFoI.js → xychartDiagram-2RQKCTM6-B8VnS2Pb.js} +1 -1
  79. package/workframe-ui/public/index.html +1 -1
  80. package/workframe-ui/public/assets/architecture-7EHR7CIX-CmDi28hh.js +0 -1
  81. package/workframe-ui/public/assets/channel-DZYVoEdD.js +0 -1
  82. package/workframe-ui/public/assets/chunk-QZHKN3VN-DwiajAaK.js +0 -1
  83. package/workframe-ui/public/assets/chunk-WU5MYG2G-Bz8vA5s7.js +0 -1
  84. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DQiq8od0.js +0 -1
  85. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DQiq8od0.js +0 -1
  86. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BXUnTe0c.js +0 -1
  87. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-aUpqRAhy.js +0 -1
@@ -1101,13 +1101,13 @@ def _provider_connected_for_user(
1101
1101
  return False
1102
1102
 
1103
1103
 
1104
- def _profile_slug(user_id: str) -> str:
1104
+ def _user_hermes_dir_slug(user_id: str) -> str:
1105
1105
  raw = str(user_id or "").strip()
1106
1106
  return re.sub(r"[^A-Za-z0-9._-]+", "_", raw) or "user"
1107
1107
 
1108
1108
 
1109
1109
  def _user_hermes_home(user_id: str) -> Path:
1110
- return HERMES_DATA / "profiles" / _profile_slug(user_id)
1110
+ return HERMES_DATA / "profiles" / _user_hermes_dir_slug(user_id)
1111
1111
 
1112
1112
 
1113
1113
  def _user_hermes_env_path(user_id: str) -> Path:
@@ -2239,7 +2239,7 @@ def _remove_auth_metadata(auth_path: Path, credential_ref: str) -> None:
2239
2239
 
2240
2240
 
2241
2241
  def _hermes_user_home_container(user_id: str) -> str:
2242
- return f"/opt/data/profiles/{_profile_slug(user_id)}"
2242
+ return f"/opt/data/profiles/{_user_hermes_dir_slug(user_id)}"
2243
2243
 
2244
2244
 
2245
2245
  def _hermes_user_exec(user_id: str, args: list[str]) -> tuple[int, str]:
@@ -2274,7 +2274,7 @@ def _hermes_user_exec(user_id: str, args: list[str]) -> tuple[int, str]:
2274
2274
  f"mkdir -p {shlex.quote(home)}; cd {shlex.quote(home)}; "
2275
2275
  f"/opt/hermes/bin/hermes {inner}"
2276
2276
  )
2277
- return _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=_profile_slug(user_id))
2277
+ return _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=_user_hermes_dir_slug(user_id))
2278
2278
 
2279
2279
 
2280
2280
  def _user_provider_bindings(user_id: str) -> dict[str, dict[str, Any]]:
@@ -2312,6 +2312,108 @@ def _hermes_auth_id_for_spec(spec: dict[str, Any]) -> str:
2312
2312
  return str(spec.get("hermes_auth_id") or spec.get("id") or "").strip()
2313
2313
 
2314
2314
 
2315
+ def _hermes_oauth_auth_keys(hermes_auth_id: str) -> set[str]:
2316
+ raw = str(hermes_auth_id or "").strip().lower()
2317
+ if not raw:
2318
+ return set()
2319
+ keys = {raw, raw.replace("-", ""), raw.replace("-", "_")}
2320
+ if "-" in raw:
2321
+ keys.add(raw.split("-")[-1])
2322
+ return {key for key in keys if key}
2323
+
2324
+
2325
+ def _oauth_llm_provider_spec(provider: str) -> dict[str, Any] | None:
2326
+ spec = _catalog_provider_for_llm(provider)
2327
+ if spec and str(spec.get("connect_mode") or "") == "oauth":
2328
+ return spec
2329
+ return None
2330
+
2331
+
2332
+ def _auth_json_has_oauth_material(data: dict[str, Any]) -> bool:
2333
+ providers = data.get("providers")
2334
+ if isinstance(providers, dict) and providers:
2335
+ return True
2336
+ pool = data.get("credential_pool")
2337
+ if isinstance(pool, dict) and any(isinstance(entries, list) and entries for entries in pool.values()):
2338
+ return True
2339
+ creds = data.get("credentials")
2340
+ return isinstance(creds, list) and bool(creds)
2341
+
2342
+
2343
+ def _extract_oauth_block_from_auth(loaded: dict[str, Any], hermes_auth_id: str) -> dict[str, Any] | None:
2344
+ keys = _hermes_oauth_auth_keys(hermes_auth_id)
2345
+ providers = loaded.get("providers")
2346
+ if isinstance(providers, dict):
2347
+ for key, entry in providers.items():
2348
+ if str(key).lower() in keys and isinstance(entry, dict):
2349
+ tokens = entry.get("tokens")
2350
+ if isinstance(tokens, dict) and any(
2351
+ str(tokens.get(field) or "").strip()
2352
+ for field in ("access_token", "refresh_token", "api_key", "id_token")
2353
+ ):
2354
+ return entry
2355
+ if entry:
2356
+ return entry
2357
+ creds = loaded.get("credentials")
2358
+ if isinstance(creds, list):
2359
+ for row in creds:
2360
+ if not isinstance(row, dict):
2361
+ continue
2362
+ pid = str(row.get("provider") or row.get("id") or "").lower()
2363
+ if pid in keys:
2364
+ return row
2365
+ return None
2366
+
2367
+
2368
+ def _merge_oauth_auth_into_profile(
2369
+ auth: dict[str, Any],
2370
+ user_auth: dict[str, Any],
2371
+ hermes_auth_id: str,
2372
+ ) -> bool:
2373
+ keys = _hermes_oauth_auth_keys(hermes_auth_id)
2374
+ changed = False
2375
+ block = _extract_oauth_block_from_auth(user_auth, hermes_auth_id)
2376
+ if isinstance(block, dict):
2377
+ merged = auth.get("providers") if isinstance(auth.get("providers"), dict) else {}
2378
+ merged[hermes_auth_id] = block
2379
+ auth["providers"] = merged
2380
+ changed = True
2381
+ user_pool = user_auth.get("credential_pool")
2382
+ if isinstance(user_pool, dict):
2383
+ pool = auth.get("credential_pool")
2384
+ if not isinstance(pool, dict):
2385
+ pool = {}
2386
+ auth["credential_pool"] = pool
2387
+ for key, entries in user_pool.items():
2388
+ if str(key).lower() in keys and isinstance(entries, list) and entries:
2389
+ pool[hermes_auth_id] = entries
2390
+ changed = True
2391
+ return changed
2392
+
2393
+
2394
+ def _sync_oauth_llm_to_profile(profile: str, user_id: str, provider: str) -> bool:
2395
+ spec = _oauth_llm_provider_spec(provider)
2396
+ if not spec:
2397
+ return False
2398
+ hermes_auth_id = _hermes_auth_id_for_spec(spec)
2399
+ if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
2400
+ return False
2401
+ user_auth = _load_user_hermes_auth(user_id)
2402
+ if not isinstance(user_auth, dict):
2403
+ return False
2404
+ prof = resolve_hermes_profile(profile)
2405
+ auth_path = _profile_dir(prof) / "auth.json"
2406
+ auth = _load_profile_auth_json(prof)
2407
+ if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
2408
+ return False
2409
+ auth["version"] = 1
2410
+ auth["updated_at"] = _utc_now()
2411
+ auth_path.parent.mkdir(parents=True, exist_ok=True)
2412
+ auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
2413
+ _publish_profile_gateway_secrets(prof)
2414
+ return True
2415
+
2416
+
2315
2417
  def _hermes_oauth_tokens_present(user_id: str, hermes_auth_id: str) -> bool:
2316
2418
  """True when Hermes auth.json has live OAuth tokens for a provider."""
2317
2419
  hermes_auth_id = str(hermes_auth_id or "").strip()
@@ -2320,23 +2422,13 @@ def _hermes_oauth_tokens_present(user_id: str, hermes_auth_id: str) -> bool:
2320
2422
  loaded = _load_user_hermes_auth(user_id)
2321
2423
  if not isinstance(loaded, dict):
2322
2424
  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
2425
+ if _extract_oauth_block_from_auth(loaded, hermes_auth_id):
2426
+ return True
2427
+ keys = _hermes_oauth_auth_keys(hermes_auth_id)
2335
2428
  pool = loaded.get("credential_pool")
2336
2429
  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:
2430
+ for key, entries in pool.items():
2431
+ if str(key).lower() in keys and isinstance(entries, list) and entries:
2340
2432
  return True
2341
2433
  return False
2342
2434
 
@@ -2351,9 +2443,9 @@ def _load_user_hermes_auth(user_id: str) -> dict[str, Any] | None:
2351
2443
  loaded = data
2352
2444
  except (OSError, json.JSONDecodeError):
2353
2445
  loaded = None
2354
- if isinstance(loaded, dict) and isinstance(loaded.get("providers"), dict) and loaded["providers"]:
2446
+ if isinstance(loaded, dict) and _auth_json_has_oauth_material(loaded):
2355
2447
  return loaded
2356
- rel = f"profiles/{_profile_slug(user_id)}/auth.json"
2448
+ rel = f"profiles/{_user_hermes_dir_slug(user_id)}/auth.json"
2357
2449
  text = _read_gateway_data_file(rel)
2358
2450
  if text.strip():
2359
2451
  try:
@@ -2488,7 +2580,7 @@ def _remove_hermes_oauth_provider(user_id: str, hermes_auth_id: str) -> None:
2488
2580
  pool.pop(key, None)
2489
2581
  loaded["updated_at"] = _utc_now()
2490
2582
  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"
2583
+ user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
2492
2584
  prefix = f"u-{user_part}-"
2493
2585
  profiles_dir = HERMES_DATA / "profiles"
2494
2586
  if profiles_dir.is_dir():
@@ -2594,25 +2686,12 @@ def _parse_device_oauth_log(text: str) -> dict[str, str | None]:
2594
2686
 
2595
2687
 
2596
2688
  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():
2689
+ if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
2599
2690
  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):
2606
- 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:
2691
+ user_auth = _load_user_hermes_auth(user_id)
2692
+ if not isinstance(user_auth, dict):
2614
2693
  return
2615
- user_part = re.sub(r"[^a-z0-9]+", "-", _profile_slug(user_id).lower()).strip("-")[:20] or "user"
2694
+ user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
2616
2695
  prefix = f"u-{user_part}-"
2617
2696
  profiles_dir = HERMES_DATA / "profiles"
2618
2697
  if not profiles_dir.is_dir():
@@ -2622,9 +2701,11 @@ def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id:
2622
2701
  continue
2623
2702
  auth_path = prof_dir / "auth.json"
2624
2703
  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
2704
+ user_auth = _load_user_hermes_auth(user_id)
2705
+ if not isinstance(user_auth, dict):
2706
+ continue
2707
+ if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
2708
+ continue
2628
2709
  auth["version"] = 1
2629
2710
  auth["updated_at"] = _utc_now()
2630
2711
  auth_path.parent.mkdir(parents=True, exist_ok=True)
@@ -2743,7 +2824,7 @@ def device_oauth_status(user_id: str, provider_id: str, session_id: str) -> dict
2743
2824
  except OSError:
2744
2825
  log_text = ""
2745
2826
  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}")
2827
+ log_text = _read_gateway_data_file(f"profiles/{_user_hermes_dir_slug(user_id)}/{log_path.name}")
2747
2828
  parsed = _parse_device_oauth_log(log_text)
2748
2829
  patch: dict[str, Any] = {}
2749
2830
  if parsed.get("verification_uri"):
@@ -4990,6 +5071,8 @@ def bootstrap_agent_dm_lane(
4990
5071
  """Provision u-* runtime, model/proxy, DM room, and optional session bind — install/create-agent parity."""
4991
5072
  user_id = str(user_id or "").strip()
4992
5073
  workspace_id = str(workspace_id or "").strip()
5074
+ if not _native_profile_present():
5075
+ _ensure_native_hermes_profile()
4993
5076
  template = resolve_validated_profile(str(template_slug or "").strip())
4994
5077
  if not user_id or not workspace_id:
4995
5078
  return {"ok": False, "error": "user_id and workspace_id required"}
@@ -5137,7 +5220,7 @@ def bootstrap_agent_dm_lane(
5137
5220
 
5138
5221
  def _runtime_profile_slug(user_id: str, template_slug: str) -> str:
5139
5222
  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"
5223
+ user_part = re.sub(r"[^a-z0-9]+", "-", _user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
5141
5224
  slug = f"u-{user_part}-{template_slug}"
5142
5225
  if len(slug) > 64:
5143
5226
  slug = slug[:64].rstrip("-")
@@ -5169,7 +5252,8 @@ def _prepare_runtime_profile_credentials(
5169
5252
  return False
5170
5253
  _strip_profile_llm_env(runtime)
5171
5254
  _strip_profile_action_env(runtime)
5172
- prov = _llm_billing_provider(runtime)
5255
+ _reconcile_profile_llm_for_user(runtime, user, workspace_id)
5256
+ prov = _llm_billing_provider(runtime, user_id=user, workspace_id=workspace_id)
5173
5257
  _ensure_profile_llm_proxy(runtime, prov)
5174
5258
  return _user_can_use_llm(user, workspace_id, prov)
5175
5259
 
@@ -7601,10 +7685,6 @@ def _bootstrap_after_setup(agent_personality: str = "") -> dict[str, Any]:
7601
7685
  if personality:
7602
7686
  _seed_native_user_overlay(profile, profile, user_soul=personality)
7603
7687
  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
7688
  return result
7609
7689
 
7610
7690
 
@@ -7858,6 +7938,110 @@ def _list_profiles() -> list[str]:
7858
7938
  )
7859
7939
 
7860
7940
 
7941
+ def _native_profile_slug() -> str:
7942
+ return str(NATIVE_PROFILE or "workframe-agent").strip() or "workframe-agent"
7943
+
7944
+
7945
+ def _native_profile_present() -> bool:
7946
+ slug = _native_profile_slug()
7947
+ prof_dir = _profile_dir(slug)
7948
+ return (prof_dir / "config.yaml").is_file() or (prof_dir / "profile.yaml").is_file()
7949
+
7950
+
7951
+ def _hermes_data_uid_gid() -> tuple[int, int]:
7952
+ try:
7953
+ st = HERMES_DATA.stat()
7954
+ return int(st.st_uid), int(st.st_gid)
7955
+ except OSError:
7956
+ return 10000, 10000
7957
+
7958
+
7959
+ def _chown_profile_tree(prof_dir: Path) -> None:
7960
+ """Gateway runs as the Agents volume owner — seeded files must match."""
7961
+ uid, gid = _hermes_data_uid_gid()
7962
+ prof_dir.mkdir(parents=True, exist_ok=True)
7963
+ (prof_dir / "logs").mkdir(parents=True, exist_ok=True)
7964
+ for path in [prof_dir, *prof_dir.rglob("*")]:
7965
+ try:
7966
+ os.chown(path, uid, gid)
7967
+ except OSError:
7968
+ pass
7969
+
7970
+
7971
+ def _seed_native_profile_on_disk(slug: str) -> bool:
7972
+ """Filesystem fallback when Hermes CLI cannot run (empty / crash-looping gateway)."""
7973
+ slug = safe_profile_slug(slug)
7974
+ prof_dir = _profile_dir(slug)
7975
+ prof_dir.mkdir(parents=True, exist_ok=True)
7976
+ config = prof_dir / "config.yaml"
7977
+ if not config.is_file():
7978
+ proxy = _llm_proxy_base_url("openrouter")
7979
+ config.write_text(
7980
+ "model:\n"
7981
+ " default: google/gemini-2.5-flash\n"
7982
+ " provider: custom\n"
7983
+ f" base_url: {proxy}\n",
7984
+ encoding="utf-8",
7985
+ )
7986
+ soul = prof_dir / "SOUL.md"
7987
+ if not soul.is_file():
7988
+ soul.write_text(
7989
+ f"You are the {PROJECT_NAME} native agent — orchestrator and workspace admin.\n",
7990
+ encoding="utf-8",
7991
+ )
7992
+ _ensure_profile_toolsets(slug)
7993
+ _ensure_profile_terminal_cwd(slug)
7994
+ _register_profile_route(
7995
+ slug,
7996
+ {
7997
+ "display_name": f"{PROJECT_NAME} Agent",
7998
+ "role": f"{PROJECT_NAME} Manager",
7999
+ },
8000
+ )
8001
+ routes_path = ROUTES_JSON
8002
+ if routes_path.is_file():
8003
+ try:
8004
+ data = json.loads(routes_path.read_text(encoding="utf-8"))
8005
+ if isinstance(data, dict) and not data.get("default_profile"):
8006
+ data["default_profile"] = slug
8007
+ routes_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
8008
+ except (OSError, json.JSONDecodeError):
8009
+ pass
8010
+ _chown_profile_tree(prof_dir)
8011
+ return _native_profile_present()
8012
+
8013
+
8014
+ def _ensure_native_hermes_profile() -> bool:
8015
+ """Idempotent: native workframe-agent must exist before gateway boot or DM bootstrap."""
8016
+ slug = _native_profile_slug()
8017
+ if _native_profile_present():
8018
+ return True
8019
+ shell = (
8020
+ "export HERMES_HOME=/opt/data HOME=/opt/data; "
8021
+ f"/opt/hermes/bin/hermes profile create {shlex.quote(slug)} --clone "
8022
+ f"--description {shlex.quote(f'{PROJECT_NAME} native agent')}"
8023
+ )
8024
+ try:
8025
+ code, out = _gateway_container_exec(["sh", "-lc", shell])
8026
+ if code == 0 and _native_profile_present():
8027
+ _register_profile_route(
8028
+ slug,
8029
+ {"display_name": f"{PROJECT_NAME} Agent", "role": f"{PROJECT_NAME} Manager"},
8030
+ )
8031
+ _chown_profile_tree(_profile_dir(slug))
8032
+ return True
8033
+ if "already exists" in out.lower() and _native_profile_present():
8034
+ _register_profile_route(
8035
+ slug,
8036
+ {"display_name": f"{PROJECT_NAME} Agent", "role": f"{PROJECT_NAME} Manager"},
8037
+ )
8038
+ _chown_profile_tree(_profile_dir(slug))
8039
+ return True
8040
+ except Exception:
8041
+ pass
8042
+ return _seed_native_profile_on_disk(slug)
8043
+
8044
+
7861
8045
  def _primary_profile() -> str:
7862
8046
  if NATIVE_PROFILE and (HERMES_DATA / "profiles" / NATIVE_PROFILE).is_dir():
7863
8047
  return NATIVE_PROFILE
@@ -11297,6 +11481,10 @@ def _ensure_profile_llm_proxy(profile: str, provider: str) -> None:
11297
11481
  """Point Hermes at the internal LLM proxy (create/lease paths only — skip when ready)."""
11298
11482
  llm_provider = str(provider or "openrouter").strip().lower()
11299
11483
  prof = safe_profile_slug(profile)
11484
+ if _oauth_llm_provider_spec(llm_provider):
11485
+ _normalize_profile_config_yaml(prof)
11486
+ _apply_mvp_model_for_provider(prof, llm_provider)
11487
+ return
11300
11488
  _normalize_profile_config_yaml(prof)
11301
11489
  if _profile_llm_proxy_ready(prof, llm_provider):
11302
11490
  _ensure_profile_proxy_headers(prof)
@@ -11449,6 +11637,16 @@ def _apply_turn_credential_lease(
11449
11637
  resolved = _require_runtime_owner_provider(user_id, workspace_id, provider)
11450
11638
  else:
11451
11639
  resolved = _require_user_provider(user_id, workspace_id, provider)
11640
+ oauth_spec = _oauth_llm_provider_spec(provider)
11641
+ if oauth_spec and str(resolved.get("credential_type") or "") == "oauth":
11642
+ _sync_oauth_llm_to_profile(prof, user_id, provider)
11643
+ current_model = str(_read_model_from_config(prof)[1] or "").strip()
11644
+ if not _resolve_billing_provider_for_model(current_model, {provider}):
11645
+ current_model = str(
11646
+ (PROVIDER_MVP_MODELS.get(provider) or {}).get("primary") or current_model
11647
+ ).strip()
11648
+ _apply_model_for_billing_provider(prof, provider, current_model, user_id)
11649
+ return ""
11452
11650
  binding_id = str(
11453
11651
  resolved.get("credential_binding_id") or resolved.get("credential_id") or ""
11454
11652
  ).strip()
@@ -11591,6 +11789,26 @@ def _require_user_provider(user_id: str, workspace_id: str, provider: str) -> di
11591
11789
 
11592
11790
  def _require_runtime_owner_provider(user_id: str, workspace_id: str, provider: str) -> dict[str, Any]:
11593
11791
  provider = str(provider or "openrouter").strip().lower()
11792
+ oauth_spec = _oauth_llm_provider_spec(provider)
11793
+ if oauth_spec and _hermes_oauth_tokens_present(user_id, _hermes_auth_id_for_spec(oauth_spec)):
11794
+ hermes_auth_id = _hermes_auth_id_for_spec(oauth_spec)
11795
+ return {
11796
+ "credential_binding_id": None,
11797
+ "credential_id": None,
11798
+ "credential_ref": f"oauth:{hermes_auth_id}",
11799
+ "scope": "user",
11800
+ "provider": provider,
11801
+ "credential_type": "oauth",
11802
+ "label": hermes_auth_id,
11803
+ "env_var": "",
11804
+ "user_id": user_id,
11805
+ "workspace_id": None,
11806
+ "agent_profile_id": None,
11807
+ "created_by": user_id,
11808
+ "created_at": None,
11809
+ "updated_at": None,
11810
+ "expires_at": None,
11811
+ }
11594
11812
  resolved = _resolve_credential(user_id, workspace_id, provider, user_only=True)
11595
11813
  if resolved and _credential_secret(resolved, user_id):
11596
11814
  return resolved
@@ -12346,8 +12564,19 @@ def _provider_pool_has_entries(auth: dict[str, Any], provider: str) -> bool:
12346
12564
  pool = auth.get("credential_pool")
12347
12565
  if not isinstance(pool, dict):
12348
12566
  return False
12349
- entries = pool.get(provider)
12350
- return isinstance(entries, list) and len(entries) > 0
12567
+ provider_key = str(provider or "").strip().lower()
12568
+ for key in _hermes_oauth_auth_keys(provider_key) | {provider_key}:
12569
+ entries = pool.get(key)
12570
+ if isinstance(entries, list) and len(entries) > 0:
12571
+ return True
12572
+ spec = _catalog_provider_for_llm(provider_key)
12573
+ if spec:
12574
+ auth_id = str(spec.get("hermes_auth_id") or "").strip().lower()
12575
+ if auth_id:
12576
+ entries = pool.get(auth_id)
12577
+ if isinstance(entries, list) and len(entries) > 0:
12578
+ return True
12579
+ return False
12351
12580
 
12352
12581
 
12353
12582
  def _connected_provider_names(user_id: str = "", workspace_id: str = "") -> set[str]:
@@ -12550,13 +12779,14 @@ def _llm_billing_provider(
12550
12779
  prof = resolve_hermes_profile(profile)
12551
12780
  block = _read_model_block(prof)
12552
12781
  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()
12782
+ billing_cfg = _billing_provider_id_from_hermes_config(cfg_provider)
12783
+ user = str(user_id or "").strip()
12784
+ ws = str(workspace_id or "").strip()
12785
+ if billing_cfg and user and _user_can_use_llm(user, ws, billing_cfg):
12786
+ return billing_cfg
12555
12787
  candidate = _billing_provider_from_block(
12556
12788
  str(block.get("base_url") or ""), cfg_provider, provider_hint,
12557
12789
  )
12558
- user = str(user_id or "").strip()
12559
- ws = str(workspace_id or "").strip()
12560
12790
  if user and not _user_can_use_llm(user, ws, candidate):
12561
12791
  connected = sorted(_user_llm_providers_for_picker(user))
12562
12792
  if connected:
@@ -12591,6 +12821,12 @@ def _reconcile_profile_llm_for_user(
12591
12821
  if not connected:
12592
12822
  return False
12593
12823
  billing = connected[0]
12824
+ if _oauth_llm_provider_spec(billing) and _user_can_use_llm(user, workspace_id, billing):
12825
+ current = str(_read_model_from_config(prof)[1] or "").strip()
12826
+ if not _resolve_billing_provider_for_model(current, {billing}):
12827
+ current = str((PROVIDER_MVP_MODELS.get(billing) or {}).get("primary") or "")
12828
+ _apply_model_for_billing_provider(prof, billing, current, user_id=user)
12829
+ return True
12594
12830
  if _user_can_use_llm(user, workspace_id, billing) and _profile_llm_proxy_matches_billing(prof, billing):
12595
12831
  return False
12596
12832
  changed = _apply_mvp_model_for_provider(prof, billing)
@@ -12637,26 +12873,9 @@ def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id:
12637
12873
  llm_spec
12638
12874
  and str(llm_spec.get("connect_mode") or "") == "oauth"
12639
12875
  and user
12640
- and _hermes_oauth_tokens_present(user, _hermes_auth_id_for_spec(llm_spec))
12876
+ and _sync_oauth_llm_to_profile(prof, user, llm_provider)
12641
12877
  ):
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
12878
+ return True
12660
12879
 
12661
12880
  if _is_runtime_profile_slug(prof):
12662
12881
  if not user or not _user_can_use_llm(user, workspace_id, llm_provider):
@@ -14454,7 +14673,10 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
14454
14673
  },
14455
14674
  "codex": {
14456
14675
  "primary": "gpt-5.4-medium",
14457
- "fallbacks": [{"provider": "openai-codex", "model": "gpt-5.4-medium"}],
14676
+ "fallbacks": [
14677
+ {"provider": "openai-codex", "model": "gpt-5.4-medium"},
14678
+ {"provider": "openai-codex", "model": "gpt-5.4-mini"},
14679
+ ],
14458
14680
  },
14459
14681
  "nous": {
14460
14682
  "primary": "nousresearch/hermes-4-70b",
@@ -14463,6 +14685,185 @@ PROVIDER_MVP_MODELS: dict[str, dict[str, Any]] = {
14463
14685
  }
14464
14686
 
14465
14687
 
14688
+ # ponytail: billing id (connect catalog) ↔ Hermes config model.provider ↔ picker rows
14689
+ _PROVIDER_CATALOG_TAGS: dict[str, frozenset[str]] = {
14690
+ "codex": frozenset({"codex", "openai"}),
14691
+ "openai": frozenset({"openai"}),
14692
+ "openrouter": frozenset({"openrouter"}),
14693
+ "anthropic": frozenset({"anthropic"}),
14694
+ "google": frozenset({"google"}),
14695
+ "deepseek": frozenset({"deepseek"}),
14696
+ "nous": frozenset({"nous"}),
14697
+ }
14698
+
14699
+ _CODEX_EXTRA_MODELS: tuple[tuple[str, str, str], ...] = (
14700
+ ("gpt-5.4-medium", "GPT-5.4 Medium", "Codex default — agentic, tool-capable."),
14701
+ ("gpt-5.4-mini", "GPT-5.4 Mini", "Faster Codex model on ChatGPT account."),
14702
+ )
14703
+
14704
+
14705
+ def _billing_provider_id_from_hermes_config(cfg_provider: str) -> str:
14706
+ p = str(cfg_provider or "").strip().lower()
14707
+ if p in {"openai-codex", "openai_codex", "codex"}:
14708
+ return "codex"
14709
+ if p in PROVIDER_MVP_MODELS:
14710
+ return p
14711
+ return ""
14712
+
14713
+
14714
+ def _catalog_tags_for_billing_provider(provider_id: str) -> frozenset[str]:
14715
+ return _PROVIDER_CATALOG_TAGS.get(str(provider_id or "").strip().lower(), frozenset())
14716
+
14717
+
14718
+ def _catalog_row_for_billing_provider(row: dict[str, str], provider_id: str) -> bool:
14719
+ tags = _catalog_tags_for_billing_provider(provider_id)
14720
+ if not tags:
14721
+ return False
14722
+ row_tag = str(row.get("provider") or "").strip().lower()
14723
+ return row_tag in tags
14724
+
14725
+
14726
+ def _provider_display_label(provider_id: str) -> str:
14727
+ spec = _catalog_provider(provider_id) or {}
14728
+ return str(spec.get("label") or provider_id).strip() or provider_id
14729
+
14730
+
14731
+ def _model_catalog_rows_for_provider(provider_id: str) -> list[dict[str, str]]:
14732
+ """Curated models a connected billing provider can run."""
14733
+ provider_key = str(provider_id or "").strip().lower()
14734
+ label = _provider_display_label(provider_key)
14735
+ rows: list[dict[str, str]] = []
14736
+ seen: set[str] = set()
14737
+
14738
+ def _add(model: str, row_label: str, description: str) -> None:
14739
+ mid = str(model or "").strip()
14740
+ if not mid or mid in seen:
14741
+ return
14742
+ seen.add(mid)
14743
+ rows.append(
14744
+ {
14745
+ "provider": label,
14746
+ "billing_provider": provider_key,
14747
+ "model": mid,
14748
+ "label": row_label,
14749
+ "description": description,
14750
+ }
14751
+ )
14752
+
14753
+ if provider_key == "codex":
14754
+ for model, row_label, description in _CODEX_EXTRA_MODELS:
14755
+ _add(model, row_label, description)
14756
+ mvp = PROVIDER_MVP_MODELS.get(provider_key) or {}
14757
+ primary = str(mvp.get("primary") or "").strip()
14758
+ if primary:
14759
+ _add(primary, primary, f"Default for {label}.")
14760
+ for fb in mvp.get("fallbacks") or []:
14761
+ if isinstance(fb, dict):
14762
+ _add(str(fb.get("model") or ""), str(fb.get("model") or ""), f"Fallback for {label}.")
14763
+ for row in HERMES_MODEL_CATALOG:
14764
+ if _catalog_row_for_billing_provider(row, provider_key):
14765
+ _add(
14766
+ str(row.get("model") or ""),
14767
+ str(row.get("label") or row.get("model") or ""),
14768
+ str(row.get("description") or ""),
14769
+ )
14770
+ return rows
14771
+
14772
+
14773
+ def _suggestions_for_connected_llm_providers(connected: set[str]) -> list[dict[str, str]]:
14774
+ out: list[dict[str, str]] = []
14775
+ seen: set[str] = set()
14776
+ for provider_id in sorted(connected):
14777
+ for row in _model_catalog_rows_for_provider(provider_id):
14778
+ mid = str(row.get("model") or "").strip()
14779
+ if not mid or mid in seen:
14780
+ continue
14781
+ seen.add(mid)
14782
+ out.append(row)
14783
+ return out
14784
+
14785
+
14786
+ def _resolve_billing_provider_for_model(model_id: str, connected: set[str]) -> str:
14787
+ mid = str(model_id or "").strip()
14788
+ if not mid or not connected:
14789
+ return ""
14790
+ for provider_id in sorted(connected):
14791
+ for row in _model_catalog_rows_for_provider(provider_id):
14792
+ if str(row.get("model") or "").strip() == mid:
14793
+ return provider_id
14794
+ low = mid.lower()
14795
+ if low.startswith("gpt-") and "codex" in connected:
14796
+ return "codex"
14797
+ if low.startswith("gpt-") and "openai" in connected:
14798
+ return "openai"
14799
+ if "/" in mid and "openrouter" in connected:
14800
+ return "openrouter"
14801
+ if low.startswith("claude") and "anthropic" in connected:
14802
+ return "anthropic"
14803
+ if "gemini" in low and "google" in connected:
14804
+ return "google"
14805
+ if "deepseek" in low and "deepseek" in connected:
14806
+ return "deepseek"
14807
+ return ""
14808
+
14809
+
14810
+ def _strip_profile_model_proxy_fields(profile: str) -> None:
14811
+ """OAuth/native providers: drop internal proxy base_url + lease api_key from model block."""
14812
+ config_path = _profile_gateway_config_path(profile)
14813
+ if not config_path or not config_path.is_file():
14814
+ return
14815
+ try:
14816
+ lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
14817
+ except OSError:
14818
+ return
14819
+ out: list[str] = []
14820
+ in_model = False
14821
+ for line in lines:
14822
+ stripped = line.lstrip()
14823
+ if stripped.startswith("model:") and not line.startswith((" ", "\t")):
14824
+ in_model = True
14825
+ out.append(line)
14826
+ continue
14827
+ if in_model and stripped and not line.startswith((" ", "\t", "#")):
14828
+ in_model = False
14829
+ if in_model and stripped.startswith(("base_url:", "api_key:")):
14830
+ continue
14831
+ out.append(line)
14832
+ try:
14833
+ config_path.write_text("".join(out), encoding="utf-8")
14834
+ except OSError:
14835
+ return
14836
+
14837
+
14838
+ def _apply_model_for_billing_provider(
14839
+ profile: str,
14840
+ billing: str,
14841
+ model_id: str,
14842
+ user_id: str = "",
14843
+ ) -> bool:
14844
+ billing = str(billing or "").strip().lower()
14845
+ ok, _ = _set_profile_model(profile, model_id)
14846
+ if not ok:
14847
+ return False
14848
+ if _oauth_llm_provider_spec(billing):
14849
+ ok_provider, _ = _set_profile_model_provider(profile, _hermes_config_provider_id(billing))
14850
+ if not ok_provider:
14851
+ return False
14852
+ _strip_profile_model_proxy_fields(profile)
14853
+ fallbacks = PROVIDER_MVP_MODELS.get(billing, {}).get("fallbacks") or []
14854
+ if isinstance(fallbacks, list):
14855
+ _write_fallback_chain(profile, fallbacks)
14856
+ user = str(user_id or "").strip()
14857
+ if user:
14858
+ _sync_oauth_llm_to_profile(profile, user, billing)
14859
+ return True
14860
+ ok_provider, _ = _set_profile_model_provider(profile, "custom")
14861
+ if not ok_provider:
14862
+ return False
14863
+ _set_profile_model_base_url(profile, _llm_proxy_base_url(billing))
14864
+ return True
14865
+
14866
+
14466
14867
  def _hermes_config_provider_id(provider_key: str) -> str:
14467
14868
  spec = _catalog_provider(provider_key)
14468
14869
  if spec and spec.get("hermes_auth_id"):
@@ -14515,19 +14916,25 @@ def _bootstrap_model_after_llm_connect(
14515
14916
  ws = str(workspace_id or "").strip()
14516
14917
  primary = _primary_profile()
14517
14918
  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:
14919
+ if user and _oauth_llm_provider_spec(provider_key):
14522
14920
  _reconcile_profile_llm_for_user(primary, user, ws, prefer_provider=provider_key)
14921
+ else:
14922
+ _, model = _read_model_from_config(primary)
14923
+ if not str(model or "").strip():
14924
+ _apply_mvp_model_for_provider(primary, provider_key)
14925
+ elif user:
14926
+ _reconcile_profile_llm_for_user(primary, user, ws, prefer_provider=provider_key)
14523
14927
  template = primary or "workframe-agent"
14524
14928
  runtime = _runtime_profile_slug(user_id, template)
14525
14929
  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:
14930
+ if user and _oauth_llm_provider_spec(provider_key):
14530
14931
  _reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
14932
+ else:
14933
+ _, model = _read_model_from_config(runtime)
14934
+ if not str(model or "").strip():
14935
+ _apply_mvp_model_for_provider(runtime, provider_key)
14936
+ elif user:
14937
+ _reconcile_profile_llm_for_user(runtime, user, ws, prefer_provider=provider_key)
14531
14938
 
14532
14939
 
14533
14940
  # Curated snapshot of commonly-used models per provider. Surfaced in the
@@ -14558,6 +14965,12 @@ HERMES_MODEL_CATALOG: list[dict[str, str]] = [
14558
14965
  "label": "o1", "description": "Reasoning model."},
14559
14966
  {"provider": "OpenAI", "model": "o1-mini",
14560
14967
  "label": "o1 mini", "description": "Cheaper reasoning."},
14968
+ {"provider": "OpenAI", "model": "gpt-5.4-mini",
14969
+ "label": "GPT-5.4 Mini", "description": "Faster Codex / OpenAI model."},
14970
+ {"provider": "Codex", "model": "gpt-5.4-medium",
14971
+ "label": "GPT-5.4 Medium (Codex)", "description": "Codex ChatGPT account default."},
14972
+ {"provider": "Codex", "model": "gpt-5.4-mini",
14973
+ "label": "GPT-5.4 Mini (Codex)", "description": "Faster Codex model."},
14561
14974
  {"provider": "Google", "model": "gemini-2.5-pro",
14562
14975
  "label": "Gemini 2.5 Pro",
14563
14976
  "description": "Long context, strong reasoning."},
@@ -14941,27 +15354,17 @@ def hermes_models(profile: str = "", user_id: str = "", workspace_id: str = "")
14941
15354
  and str(spec.get("id")).lower() in {n.lower() for n in connected}
14942
15355
  for spec in PROVIDER_CONNECT_CATALOG
14943
15356
  )
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
14955
- )
14956
-
15357
+ connected_llm = picker_llm if user_id else {
15358
+ str(spec["id"]).lower()
15359
+ for spec in PROVIDER_CONNECT_CATALOG
15360
+ if str(spec.get("category") or "") == "llm"
15361
+ and str(spec.get("id")).lower() in {n.lower() for n in connected}
15362
+ }
14957
15363
  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 [],
15364
+ _suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
14962
15365
  block,
14963
15366
  )
14964
- if user_id and "openrouter" in {n.lower() for n in (picker_llm or connected)}:
15367
+ if user_id and "openrouter" in connected_llm:
14965
15368
  resolved = _resolve_credential(user_id, workspace_id, "openrouter", user_only=True)
14966
15369
  secret = _credential_secret(resolved or {}, user_id) if resolved else ""
14967
15370
  live = openrouter_catalog.live_suggestions(secret, limit=30)
@@ -15020,20 +15423,39 @@ def hermes_apply_default_model_config() -> dict[str, Any]:
15020
15423
  }
15021
15424
 
15022
15425
 
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
- """
15426
+ def hermes_model_set(
15427
+ profile: str,
15428
+ model_id: str,
15429
+ user_id: str = "",
15430
+ workspace_id: str = "",
15431
+ ) -> dict[str, Any]:
15432
+ """Set primary model and align Hermes provider routing to a connected billing provider."""
15028
15433
  target = profile or _primary_profile()
15029
15434
  if not target:
15030
15435
  return {"ok": False, "error": "no profile resolved"}
15031
15436
  if not model_id or " " in model_id or "\n" in model_id:
15032
15437
  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}
15438
+ user = str(user_id or "").strip()
15439
+ ws = str(workspace_id or "").strip()
15440
+ connected = _user_llm_providers_for_picker(user) if user else set()
15441
+ billing = _resolve_billing_provider_for_model(model_id, connected)
15442
+ if billing:
15443
+ if not _apply_model_for_billing_provider(target, billing, model_id, user_id=user):
15444
+ return {"ok": False, "error": "model apply failed"}
15445
+ else:
15446
+ ok, err = _set_profile_model(target, model_id)
15447
+ if not ok:
15448
+ return {"ok": False, "error": err}
15449
+ block = _read_model_block(target)
15450
+ return {
15451
+ "ok": True,
15452
+ "profile": target,
15453
+ "model": model_id,
15454
+ "provider": str(block.get("provider") or ""),
15455
+ "billing_provider": billing or _billing_provider_id_from_hermes_config(
15456
+ str(block.get("provider") or "")
15457
+ ),
15458
+ }
15037
15459
 
15038
15460
 
15039
15461
  def hermes_fallback_chain_set(profile: str, chain: list[Any]) -> dict[str, Any]:
@@ -15844,6 +16266,11 @@ class Handler(BaseHTTPRequestHandler):
15844
16266
  except Exception:
15845
16267
  return self._json(200, {"setup_complete": False})
15846
16268
  if path == "/api/install/status":
16269
+ if install_api.install_window_open(str(_workframe_db_path())):
16270
+ try:
16271
+ _ensure_native_hermes_profile()
16272
+ except Exception:
16273
+ pass
15847
16274
  payload = install_api.install_status_payload(
15848
16275
  DEPLOYMENT_MODE,
15849
16276
  SECURE_MODE,
@@ -16847,6 +17274,8 @@ class Handler(BaseHTTPRequestHandler):
16847
17274
 
16848
17275
  def _complete_install(self, user_id: str, body: dict[str, Any]) -> dict[str, Any]:
16849
17276
  global DEPLOYMENT_MODE
17277
+ if not _native_profile_present():
17278
+ _ensure_native_hermes_profile()
16850
17279
  data = body if isinstance(body, dict) else {}
16851
17280
  if not user_id and DEPLOYMENT_MODE == "single_user_local":
16852
17281
  user_id = str(data.get("user_id") or secrets.token_hex(8))
@@ -18536,7 +18965,7 @@ class Handler(BaseHTTPRequestHandler):
18536
18965
  return self._json(400, {"error": "model required"})
18537
18966
  if profile:
18538
18967
  _bootstrap_profile_providers(resolve_validated_profile(profile), user_id, workspace_id)
18539
- return self._json(200, hermes_model_set(profile, model_id))
18968
+ return self._json(200, hermes_model_set(profile, model_id, user_id, workspace_id))
18540
18969
  if path == "/api/hermes/model/apply-default":
18541
18970
  if not _check_auth(self):
18542
18971
  return self._json(401, {"error": "unauthorized"})