create-workframe 0.1.17 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +73 -9
  3. package/package.json +1 -1
  4. package/scripts/apply-update-workframe.sh +21 -18
  5. package/scripts/compose-docker-host.sh +26 -0
  6. package/workframe-api/action_proxy.py +8 -0
  7. package/workframe-api/chat_sessions.py +37 -6
  8. package/workframe-api/chat_stream.py +16 -0
  9. package/workframe-api/db_schema.py +29 -0
  10. package/workframe-api/handler_modules/handler_admin.py +32 -0
  11. package/workframe-api/handler_modules/handler_auth.py +6 -4
  12. package/workframe-api/handler_modules/handler_chat.py +17 -26
  13. package/workframe-api/handler_modules/handler_provider.py +13 -14
  14. package/workframe-api/handler_modules/handler_workspace.py +60 -2
  15. package/workframe-api/hermes_profiles.py +31 -9
  16. package/workframe-api/llm_proxy.py +18 -0
  17. package/workframe-api/mention_invoke.py +29 -24
  18. package/workframe-api/message_reactions.py +139 -0
  19. package/workframe-api/model_surface.py +329 -113
  20. package/workframe-api/package.json +4 -4
  21. package/workframe-api/profile_gateway.py +14 -6
  22. package/workframe-api/provider_bindings.py +118 -17
  23. package/workframe-api/provider_bootstrap.py +74 -38
  24. package/workframe-api/provider_model_catalog.py +261 -0
  25. package/workframe-api/rooms.py +4 -3
  26. package/workframe-api/route_registry.py +4 -0
  27. package/workframe-api/runtime_cohort.py +118 -23
  28. package/workframe-api/scripts/run-python.mjs +28 -0
  29. package/workframe-api/server.py +44 -12
  30. package/workframe-api/test_agent_identity_authority.py +96 -0
  31. package/workframe-api/test_chat_attribution_history.py +59 -0
  32. package/workframe-api/test_enrich_room_messages.py +43 -2
  33. package/workframe-api/test_llm_proxy_encoding.py +33 -0
  34. package/workframe-api/test_message_reactions.py +151 -0
  35. package/workframe-api/test_model_routing_reconcile.py +122 -0
  36. package/workframe-api/test_model_surface_consistency.py +45 -5
  37. package/workframe-api/test_oauth_llm.py +1 -1
  38. package/workframe-api/test_profile_gateway_health.py +37 -0
  39. package/workframe-api/test_provider_bindings.py +115 -1
  40. package/workframe-api/test_provider_model_catalog.py +27 -0
  41. package/workframe-api/test_provider_model_surface.py +2 -2
  42. package/workframe-api/test_runtime_lease_lifecycle.py +93 -0
  43. package/workframe-api/test_server_reexports.py +2 -0
  44. package/workframe-api/test_user_llm_prefs.py +27 -0
  45. package/workframe-api/test_workspace_file_actions.py +119 -0
  46. package/workframe-api/turn_credentials.py +41 -0
  47. package/workframe-api/turn_overlay.py +95 -34
  48. package/workframe-api/updates.py +14 -5
  49. package/workframe-api/user_prefs.py +15 -0
  50. package/workframe-api/workspace_bootstrap.py +1 -0
  51. package/workframe-api/workspace_files.py +124 -0
  52. package/workframe-supervisor/server.py +18 -2
  53. package/workframe-ui/public/assets/{arc-esSBm9C_.js → arc-BkATcoqt.js} +1 -1
  54. package/workframe-ui/public/assets/architecture-7EHR7CIX-yusDFKmK.js +1 -0
  55. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DVVz9RGn.js → architectureDiagram-3BPJPVTR-31_yAiUS.js} +1 -1
  56. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-B6i10GMX.js → blockDiagram-GPEHLZMM-ClM6Si9T.js} +1 -1
  57. package/workframe-ui/public/assets/{brave-B885JYnH.svg → brave-BoYMbZi1.svg} +0 -1
  58. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BqwJ7JoH.js → c4Diagram-AAUBKEIU-DJzEktF3.js} +1 -1
  59. package/workframe-ui/public/assets/channel-Nb_UjZ3O.js +1 -0
  60. package/workframe-ui/public/assets/{chunk-2J33WTMH-CyhcedSa.js → chunk-2J33WTMH-DPFiyb5O.js} +1 -1
  61. package/workframe-ui/public/assets/{chunk-3OPIFGDE-1Rr6WuvI.js → chunk-3OPIFGDE-DXNJzuCn.js} +1 -1
  62. package/workframe-ui/public/assets/{chunk-4BX2VUAB-D2bRrARV.js → chunk-4BX2VUAB-DDNibGdr.js} +1 -1
  63. package/workframe-ui/public/assets/{chunk-55IACEB6-CKXeZxSr.js → chunk-55IACEB6-Co3_ulwA.js} +1 -1
  64. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DYQxxPZ4.js → chunk-5ZQYHXKU-Cmxz8o4d.js} +1 -1
  65. package/workframe-ui/public/assets/{chunk-727SXJPM-CGpIgqvD.js → chunk-727SXJPM-CDAQGesU.js} +1 -1
  66. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-B88NGQ7d.js → chunk-AQP2D5EJ-x-swy3kc.js} +1 -1
  67. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CCymiI0O.js → chunk-BSJP7CBP-28nU0JOE.js} +1 -1
  68. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-DRgey03j.js → chunk-CSCIHK7Q-FUURFika.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-FMBD7UC4-l1adn8gM.js → chunk-FMBD7UC4-B4RgRO2R.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CaEvDRPK.js → chunk-KSCS5N6A-CkuppW3T.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-BgCnvPTv.js → chunk-L5ZTLDWV-DyrPSaz3.js} +1 -1
  72. package/workframe-ui/public/assets/{chunk-LZXEDZCA-t2zokHpn.js → chunk-LZXEDZCA-DFaZSDNV.js} +2 -2
  73. package/workframe-ui/public/assets/{chunk-ND2GUHAM-C3c9p0sv.js → chunk-ND2GUHAM-DXI0OJxg.js} +1 -1
  74. package/workframe-ui/public/assets/{chunk-NZK2D7GU-DSujF-R8.js → chunk-NZK2D7GU-CyOp1ZG2.js} +1 -1
  75. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DJJTbM6p.js → chunk-O5CBEL6O-tZZMCfbx.js} +1 -1
  76. package/workframe-ui/public/assets/chunk-QZHKN3VN-C_TtOXi5.js +1 -0
  77. package/workframe-ui/public/assets/chunk-WU5MYG2G-BjSJQEIY.js +1 -0
  78. package/workframe-ui/public/assets/{chunk-XPW4576I-D2Cxn3AG.js → chunk-XPW4576I-B7_2gS3x.js} +1 -1
  79. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-JD6iZUGh.js +1 -0
  80. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-JD6iZUGh.js +1 -0
  81. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A---dQ-pFK.js → cose-bilkent-S5V4N54A-BWJaHw_L.js} +1 -1
  82. package/workframe-ui/public/assets/{dagre-BM42HDAG-B_qe1ODY.js → dagre-BM42HDAG-Bu5MadGV.js} +1 -1
  83. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Bou8vEYl.js → diagram-2AECGRRQ-ckLZkDFD.js} +1 -1
  84. package/workframe-ui/public/assets/{diagram-5GNKFQAL-DEUEzONU.js → diagram-5GNKFQAL-Cf6Abx1Y.js} +1 -1
  85. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Ctq571VA.js → diagram-KO2AKTUF-TLk7_5mJ.js} +1 -1
  86. package/workframe-ui/public/assets/{diagram-LMA3HP47-CnKn89lm.js → diagram-LMA3HP47-ouNxsAb0.js} +1 -1
  87. package/workframe-ui/public/assets/{diagram-OG6HWLK6-DnKlrMx2.js → diagram-OG6HWLK6-BMCBQ35D.js} +1 -1
  88. package/workframe-ui/public/assets/{dist-ubrEgQyZ.js → dist-C9bxMexz.js} +1 -1
  89. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-PjqO7CJd.js → erDiagram-TEJ5UH35-BN3e6jb1.js} +1 -1
  90. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bqd_sTz0.js +1 -0
  91. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-B2PN_nNG.js → flowDiagram-I6XJVG4X-BiinzZrd.js} +1 -1
  92. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-ThyAtNQP.js → ganttDiagram-6RSMTGT7-AYE8oBBG.js} +1 -1
  93. package/workframe-ui/public/assets/{gemini-Cq-0-rmr.svg → gemini-DqnZlvMo.svg} +0 -1
  94. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-CB2UgoWy.js → gitGraph-WXDBUCRP-mIq_E2vs.js} +1 -1
  95. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-DRJJ-zT-.js → gitGraphDiagram-PVQCEYII-Cf8IFEcO.js} +1 -1
  96. package/workframe-ui/public/assets/index-BnfFRGR3.js +133 -0
  97. package/workframe-ui/public/assets/index-COshPjF3.css +1 -0
  98. package/workframe-ui/public/assets/{info-J43DQDTF-CLtR33Q_.js → info-J43DQDTF--_3kCaIQ.js} +1 -1
  99. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-Cmunqfvi.js → infoDiagram-5YYISTIA-BuIOyYMu.js} +1 -1
  100. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-DWE_84eL.js → ishikawaDiagram-YF4QCWOH-D3bbsXQ8.js} +1 -1
  101. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BtBhpyRL.js → journeyDiagram-JHISSGLW-BrZ26C8L.js} +1 -1
  102. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-B8QspDDL.js → kanban-definition-UN3LZRKU-CJiUyUOr.js} +1 -1
  103. package/workframe-ui/public/assets/{line-iOgDn4O8.js → line-Bee1snmU.js} +1 -1
  104. package/workframe-ui/public/assets/{linear-BBehZNVA.js → linear-dfD-zlI7.js} +1 -1
  105. package/workframe-ui/public/assets/{mermaid-parser.core-DQK5HcdU.js → mermaid-parser.core-D6IqqDSH.js} +2 -2
  106. package/workframe-ui/public/assets/{mermaid.core-Dsrdd1Wr.js → mermaid.core-CuuxYt2r.js} +3 -3
  107. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Ds_BfsJA.js → mindmap-definition-RKZ34NQL-T7W2fkwA.js} +1 -1
  108. package/workframe-ui/public/assets/{packet-YPE3B663-Bz3pi4i8.js → packet-YPE3B663-CiXMDasj.js} +1 -1
  109. package/workframe-ui/public/assets/{pie-LRSECV5Y-BFFah-6g.js → pie-LRSECV5Y-CUx1_96f.js} +1 -1
  110. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CVCvLPcC.js → pieDiagram-4H26LBE5-DfR96FUO.js} +1 -1
  111. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-Cx6rrc_7.js → quadrantDiagram-W4KKPZXB-Dj6mqgQE.js} +1 -1
  112. package/workframe-ui/public/assets/{radar-GUYGQ44K-DJ5vN_cg.js → radar-GUYGQ44K-DKT2q98y.js} +1 -1
  113. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-Cbry6b5c.js → requirementDiagram-4Y6WPE33-DYDnBVw9.js} +1 -1
  114. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CICUr5En.js → sankeyDiagram-5OEKKPKP-BSkxpjjO.js} +1 -1
  115. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BsOuB3F-.js → sequenceDiagram-3UESZ5HK-Dz6SNH9S.js} +1 -1
  116. package/workframe-ui/public/assets/{src-TS8jZspq.js → src-DC521nE_.js} +1 -1
  117. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-avBzMQy_.js → stateDiagram-AJRCARHV-D4ZoF5NP.js} +1 -1
  118. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DknveEiR.js +1 -0
  119. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DHLm6UFN.js → timeline-definition-PNZ67QCA-d0ezg1Hu.js} +1 -1
  120. package/workframe-ui/public/assets/{treeView-BLDUP644-B3Qd5t3j.js → treeView-BLDUP644-kXyaqwIy.js} +1 -1
  121. package/workframe-ui/public/assets/{treemap-LRROVOQU-D1dMQS7x.js → treemap-LRROVOQU-g168p7-O.js} +1 -1
  122. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-DZ6SoniY.js → vennDiagram-CIIHVFJN-C0_QsmRl.js} +1 -1
  123. package/workframe-ui/public/assets/{wardley-L42UT6IY-9gK17Tp3.js → wardley-L42UT6IY-BNFCdzxQ.js} +1 -1
  124. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-FCU_wmRE.js → wardleyDiagram-YWT4CUSO-BOwUS9sY.js} +1 -1
  125. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DWowvseN.js → xychartDiagram-2RQKCTM6-Cxr1ZiKh.js} +1 -1
  126. package/workframe-ui/public/index.html +2 -2
  127. package/workframe-ui/public/workframe-build.json +3 -3
  128. package/workframe-ui/public/assets/architecture-7EHR7CIX-j-DiBdGu.js +0 -1
  129. package/workframe-ui/public/assets/channel-CJ9pFXbx.js +0 -1
  130. package/workframe-ui/public/assets/chunk-QZHKN3VN-Bxk3enxo.js +0 -1
  131. package/workframe-ui/public/assets/chunk-WU5MYG2G-DdbkX1rg.js +0 -1
  132. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-bwCRiiUl.js +0 -1
  133. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-bwCRiiUl.js +0 -1
  134. package/workframe-ui/public/assets/eventmodeling-FCH6USID-g-Xo_eS4.js +0 -1
  135. package/workframe-ui/public/assets/index-1euVfd3b.js +0 -132
  136. package/workframe-ui/public/assets/index-BLVAsAeO.css +0 -1
  137. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-BFRA5TYD.js +0 -1
@@ -4,9 +4,9 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "python3 server.py",
8
- "start": "python3 server.py",
9
- "typecheck": "python -m py_compile server.py zk_auth.py email_sender.py profile_config_yaml.py route_registry.py domain/entities.py domain/__init__.py && python test_public_routes.py && python test_route_registry.py && python test_billing_provider.py && python test_model_surface_consistency.py && python test_profile_model_yaml.py && python test_domain_entities.py",
10
- "build": "python3 -m py_compile server.py zk_auth.py email_sender.py"
7
+ "dev": "node scripts/run-python.mjs server.py",
8
+ "start": "node scripts/run-python.mjs server.py",
9
+ "typecheck": "node scripts/run-python.mjs -m py_compile server.py zk_auth.py email_sender.py profile_config_yaml.py route_registry.py domain/entities.py domain/__init__.py && node scripts/run-python.mjs test_public_routes.py && node scripts/run-python.mjs test_route_registry.py && node scripts/run-python.mjs test_billing_provider.py && node scripts/run-python.mjs test_model_surface_consistency.py && node scripts/run-python.mjs test_profile_model_yaml.py && node scripts/run-python.mjs test_domain_entities.py",
10
+ "build": "node scripts/run-python.mjs -m py_compile server.py zk_auth.py email_sender.py"
11
11
  }
12
12
  }
@@ -31,7 +31,13 @@ def _srv():
31
31
  def _reload_runtime_profile_gateway(profile: str, *, wait_healthy: bool = True) -> None:
32
32
  """Reload Hermes after config.yaml change — gateway restart before stop+start."""
33
33
  prof = _srv().resolve_hermes_profile(profile)
34
- if not _srv()._is_runtime_profile_slug(prof) or prof == _srv()._primary_profile():
34
+ if prof == _srv()._primary_profile():
35
+ _srv()._invalidate_profile_health_cache(prof)
36
+ _srv()._restart_stack_gateway()
37
+ if wait_healthy:
38
+ _wait_profile_api_healthy(prof, attempts=120, delay=0.5)
39
+ return
40
+ if not _srv()._is_runtime_profile_slug(prof):
35
41
  return
36
42
  with _srv()._gateway_lifecycle_lock:
37
43
  if _profile_api_healthy(prof, timeout=0.5, use_cache=False):
@@ -59,8 +65,7 @@ def _schedule_gateway_reload(profile: str) -> None:
59
65
 
60
66
  def _run() -> None:
61
67
  try:
62
- with _srv()._gateway_lifecycle_lock:
63
- _reload_runtime_profile_gateway(prof, wait_healthy=False)
68
+ _reload_runtime_profile_gateway(prof, wait_healthy=False)
64
69
  except Exception as exc: # noqa: BLE001
65
70
  print(f"[workframe-api] gateway reload failed for {prof}: {exc}")
66
71
 
@@ -363,15 +368,18 @@ def _profile_api_healthy(profile: str, timeout: float = 1.5, *, use_cache: bool
363
368
  return ok
364
369
 
365
370
 
366
- def _wait_profile_api_healthy(profile: str, attempts: int = 60, delay: float = 0.5) -> bool:
371
+ def _wait_profile_api_healthy(profile: str, attempts: int = 120, delay: float = 0.5) -> bool:
367
372
  try:
368
373
  prof = _srv().resolve_hermes_profile(profile)
369
374
  except ValueError:
370
375
  return False
371
- if _profile_api_healthy(prof):
376
+ # A compose-managed primary gateway can take ~40 seconds to restart. Do
377
+ # not let the short-lived negative health cache consume the polling window
378
+ # or the first post-install room turn can fail just before it comes up.
379
+ if _profile_api_healthy(prof, use_cache=False):
372
380
  return True
373
381
  for _ in range(attempts):
374
- if _profile_api_healthy(prof):
382
+ if _profile_api_healthy(prof, use_cache=False):
375
383
  return True
376
384
  time.sleep(delay)
377
385
  return False
@@ -45,6 +45,30 @@ def _user_provider_bindings(user_id: str) -> dict[str, dict[str, Any]]:
45
45
  return by_provider
46
46
 
47
47
 
48
+ def _workspace_provider_bindings(workspace_id: str) -> dict[str, dict[str, Any]]:
49
+ """Return the active, newest shared credential for each workspace provider."""
50
+ by_provider: dict[str, dict[str, Any]] = {}
51
+ try:
52
+ conn = sqlite3.connect(str(_srv()._workframe_db_path()), timeout=3.0)
53
+ conn.row_factory = sqlite3.Row
54
+ rows = conn.execute(
55
+ """SELECT id, provider, credential_type, credential_ref, label, is_active, updated_at
56
+ FROM credential_bindings
57
+ WHERE workspace_id = ? AND user_id IS NULL
58
+ AND deleted_at IS NULL AND is_active = 1
59
+ ORDER BY updated_at DESC, created_at DESC""",
60
+ (workspace_id,),
61
+ ).fetchall()
62
+ conn.close()
63
+ except sqlite3.Error:
64
+ rows = []
65
+ for row in rows:
66
+ provider = str(row["provider"] or "").lower()
67
+ if provider and provider not in by_provider:
68
+ by_provider[provider] = dict(row)
69
+ return by_provider
70
+
71
+
48
72
  _DEVICE_OAUTH_PROVIDER_IDS: frozenset[str] = frozenset({"codex", "nous"})
49
73
  _oauth_device_lock = threading.Lock()
50
74
  _oauth_device_sessions: dict[str, dict[str, Any]] = {}
@@ -146,15 +170,16 @@ def _sync_oauth_llm_to_profile(profile: str, user_id: str, provider: str) -> boo
146
170
  return False
147
171
  prof = _srv().resolve_hermes_profile(profile)
148
172
  auth = _load_profile_auth_json(prof)
149
- if _extract_oauth_block_from_auth(auth, hermes_auth_id):
150
- # ponytail: steady-state turns — skip user auth read when profile already has oauth
151
- return False
152
173
  user_auth = _load_user_hermes_auth(user_id)
153
174
  if not isinstance(user_auth, dict):
154
175
  return False
155
176
  auth_path = _srv()._profile_dir(prof) / "auth.json"
177
+ before = json.dumps(auth, sort_keys=True, separators=(",", ":"))
156
178
  if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
157
179
  return False
180
+ after = json.dumps(auth, sort_keys=True, separators=(",", ":"))
181
+ if after == before:
182
+ return False
158
183
  auth["version"] = 1
159
184
  auth["updated_at"] = _srv()._utc_now()
160
185
  auth_path.parent.mkdir(parents=True, exist_ok=True)
@@ -238,21 +263,47 @@ def _user_provider_connected(user_id: str, spec: dict[str, Any]) -> bool:
238
263
  return _srv()._provider_connected_for_user(user_id, spec, bindings, env_keys)
239
264
 
240
265
 
241
- def list_user_providers(user_id: str, workspace_id: str = "") -> dict[str, Any]:
266
+ def list_user_providers(
267
+ user_id: str,
268
+ workspace_id: str = "",
269
+ credential_scope: str = "effective",
270
+ ) -> dict[str, Any]:
271
+ scope = str(credential_scope or "effective").strip().lower()
272
+ if scope not in {"effective", "user", "workspace"}:
273
+ scope = "effective"
242
274
  bindings = _user_provider_bindings(user_id)
243
275
  workspace = str(workspace_id or "").strip()
276
+ workspace_bindings = _workspace_provider_bindings(workspace) if workspace else {}
277
+ workspace_mode = bool(workspace and _srv()._workspace_credential_mode(None, workspace) == "workspace")
244
278
  providers: list[dict[str, Any]] = []
245
279
  for spec in _srv().PROVIDER_CONNECT_CATALOG:
246
280
  provider_id = str(spec["id"])
247
281
  env_var = str(spec.get("env_var") or "")
248
282
  binding = bindings.get(provider_id)
249
- connected = _user_provider_connected(user_id, spec)
250
- source: str | None = "user" if connected else None
251
- if not connected and workspace and str(spec.get("category") or "") == "llm":
252
- resolved = _srv()._resolve_credential("", workspace, provider_id)
253
- if resolved and _srv()._credential_secret(resolved, user_id):
254
- connected = True
255
- source = "workspace"
283
+ workspace_binding = workspace_bindings.get(provider_id)
284
+ connected = False
285
+ source: str | None = None
286
+ selected_binding: dict[str, Any] | None = None
287
+ if scope == "workspace":
288
+ selected_binding = workspace_binding
289
+ if selected_binding:
290
+ connected = bool(_srv()._credential_secret(selected_binding, user_id))
291
+ source = "workspace" if connected else None
292
+ else:
293
+ connected = _user_provider_connected(user_id, spec)
294
+ source = "user" if connected else None
295
+ selected_binding = binding if connected else None
296
+ if (
297
+ scope == "effective"
298
+ and not connected
299
+ and workspace_mode
300
+ and str(spec.get("category") or "") == "llm"
301
+ and workspace_binding
302
+ ):
303
+ connected = bool(_srv()._credential_secret(workspace_binding, user_id))
304
+ if connected:
305
+ source = "workspace"
306
+ selected_binding = workspace_binding
256
307
  oauth_configured = None
257
308
  if str(spec.get("connect_mode") or "") == "oauth":
258
309
  oauth_name = str(spec.get("oauth_provider") or provider_id)
@@ -264,13 +315,18 @@ def list_user_providers(user_id: str, workspace_id: str = "") -> dict[str, Any]:
264
315
  **spec,
265
316
  "connected": connected,
266
317
  "source": source,
267
- "credential_id": str(binding["id"]) if binding else None,
268
- "credential_ref": str(binding["credential_ref"]) if binding else (f"env:{env_var}" if env_var and connected else None),
318
+ "credential_id": str(selected_binding["id"]) if selected_binding else None,
319
+ "credential_ref": str(selected_binding["credential_ref"]) if selected_binding else (f"env:{env_var}" if env_var and connected else None),
269
320
  "profile_home": str(_srv()._user_hermes_home(user_id)),
270
321
  "oauth_configured": oauth_configured,
271
322
  "user_only": bool(spec.get("user_only")),
272
323
  })
273
- return {"ok": True, "providers": providers, "profile_home": str(_srv()._user_hermes_home(user_id))}
324
+ return {
325
+ "ok": True,
326
+ "credential_scope": scope,
327
+ "providers": providers,
328
+ "profile_home": str(_srv()._user_hermes_home(user_id)),
329
+ }
274
330
 
275
331
 
276
332
  def disconnect_user_credential(user_id: str, credential_id: str) -> dict[str, Any]:
@@ -303,6 +359,11 @@ def disconnect_user_credential(user_id: str, credential_id: str) -> dict[str, An
303
359
  conn.close()
304
360
  except sqlite3.Error as exc:
305
361
  return {"ok": False, "error": f"db_error: {exc}"}
362
+ _srv()._revoke_runtime_llm_leases(
363
+ payer_user_id=user_id,
364
+ provider=str(row["provider"]),
365
+ credential_binding_id=credential_id,
366
+ )
306
367
  return {"ok": True, "credential_id": credential_id, "provider": str(row["provider"])}
307
368
 
308
369
 
@@ -439,14 +500,14 @@ def _device_oauth_error_from_log(log_text: str) -> str | None:
439
500
  if not clean:
440
501
  return None
441
502
  lowered = clean.lower()
442
- auth_err = re.search(r"AuthError:\s*(.+)", clean)
443
- if auth_err:
444
- return auth_err.group(1).strip()[:500]
445
503
  if "rate-limit" in lowered or "rate limiting" in lowered or "http 429" in lowered or re.search(r"\b429\b", lowered):
446
504
  return (
447
505
  "OpenAI is rate-limiting Codex login requests (HTTP 429). "
448
506
  "Wait a minute and try again."
449
507
  )
508
+ auth_err = re.search(r"AuthError:\s*(.+)", clean)
509
+ if auth_err:
510
+ return auth_err.group(1).strip()[:500]
450
511
  if any(token in lowered for token in ("login timed out", "login cancelled", "oauth_start_failed")):
451
512
  lines = [line.strip() for line in clean.splitlines() if line.strip()]
452
513
  return (lines[-1] if lines else "OAuth failed")[:500]
@@ -492,6 +553,11 @@ def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id:
492
553
  def _finalize_hermes_device_oauth(user_id: str, provider_id: str, spec: dict[str, Any]) -> None:
493
554
  hermes_auth_id = _hermes_auth_id_for_spec(spec)
494
555
  _sync_user_oauth_provider_to_runtime_profiles(user_id, hermes_auth_id)
556
+ # The picker caches connected providers for one minute. OAuth material is
557
+ # stored outside credential_bindings, so the ordinary secret-save
558
+ # invalidation path never runs; clear it before selecting/bootstraping the
559
+ # new provider or the completed Codex connection remains invisible.
560
+ _srv()._invalidate_user_llm_picker_cache(user_id)
495
561
  _srv()._bootstrap_model_after_llm_connect(user_id, "", provider_id)
496
562
 
497
563
 
@@ -508,6 +574,24 @@ def _device_oauth_session_patch(session_id: str, patch: dict[str, Any]) -> None:
508
574
  row.update(patch)
509
575
 
510
576
 
577
+ def _reusable_device_oauth_session(user_id: str, provider_id: str) -> tuple[str, dict[str, Any]] | None:
578
+ """Reuse one live device flow instead of rate-limiting the upstream provider."""
579
+ now = time.time()
580
+ with _oauth_device_lock:
581
+ candidates = [
582
+ (session_id, dict(row))
583
+ for session_id, row in _oauth_device_sessions.items()
584
+ if isinstance(row, dict)
585
+ and row.get("user_id") == user_id
586
+ and row.get("provider_id") == provider_id
587
+ and row.get("status") == "pending"
588
+ and now - float(row.get("started_at") or 0.0) < 16 * 60
589
+ ]
590
+ if not candidates:
591
+ return None
592
+ return max(candidates, key=lambda item: float(item[1].get("started_at") or 0.0))
593
+
594
+
511
595
  def _spawn_hermes_device_oauth(user_id: str, hermes_auth_id: str, log_container: str) -> tuple[int, str]:
512
596
  """Start long-running `hermes auth add` detached in gateway (survives exec return)."""
513
597
  home = _srv()._hermes_user_home_container(user_id)
@@ -544,6 +628,23 @@ def _spawn_hermes_device_oauth(user_id: str, hermes_auth_id: str, log_container:
544
628
 
545
629
  def _start_device_oauth(user_id: str, provider_id: str, spec: dict[str, Any]) -> dict[str, Any]:
546
630
  hermes_auth_id = _hermes_auth_id_for_spec(spec)
631
+ reusable = _reusable_device_oauth_session(user_id, provider_id)
632
+ if reusable:
633
+ reusable_id, _ = reusable
634
+ current = device_oauth_status(user_id, provider_id, reusable_id)
635
+ if current.get("status") != "error":
636
+ return {
637
+ "ok": True,
638
+ "provider": provider_id,
639
+ "hermes_auth_id": hermes_auth_id,
640
+ "flow": "device_code",
641
+ "session_id": reusable_id,
642
+ "status": current.get("status") or "pending",
643
+ "verification_uri": current.get("verification_uri"),
644
+ "user_code": current.get("user_code"),
645
+ "message": current.get("message"),
646
+ "reused": True,
647
+ }
547
648
  session_id = secrets.token_urlsafe(16)
548
649
  log_name = f".oauth-{session_id}.log"
549
650
  home = _srv()._hermes_user_home_container(user_id)
@@ -213,57 +213,85 @@ def _reconcile_profile_llm_for_user(
213
213
  *,
214
214
  prefer_provider: str = "",
215
215
  ) -> bool:
216
- """Point proxy + MVP model at a provider the acting user can bill."""
216
+ """Repair routing for the agent-owned model without changing that model.
217
+
218
+ Credentials authorize execution; they never silently select a different
219
+ provider and create DM/room model drift.
220
+ """
217
221
  user = str(user_id or "").strip()
218
222
  if not user:
219
223
  return False
220
224
  prof = _srv().resolve_hermes_profile(profile)
221
225
  block = _srv()._read_model_block(prof)
222
- model = str(block.get("default") or "").strip()
223
- if model:
224
- cfg = str(block.get("provider") or "").strip().lower()
225
- quick_billing = _srv()._billing_provider_id_from_hermes_config(cfg)
226
- # Direct oauth routing only — custom proxy may still need rebilling to user's provider.
227
- if quick_billing and cfg != "custom" and _srv()._profile_routing_matches_billing(
228
- prof, quick_billing, block=block,
229
- ):
230
- return False
231
- connected = _srv()._user_llm_providers_for_picker(user)
232
- current_model = model
233
- prefer = str(prefer_provider or "").strip().lower()
234
- model_billing = (
235
- _srv()._resolve_billing_provider_for_model(current_model, connected, prefer=prefer)
236
- if current_model
226
+ current_model = str(block.get("default") or "").strip()
227
+ cfg = str(block.get("provider") or "").strip().lower()
228
+ configured_billing = (
229
+ _srv()._billing_provider_from_block(
230
+ str(block.get("base_url") or ""),
231
+ cfg,
232
+ cfg,
233
+ )
234
+ if (cfg or block.get("base_url"))
237
235
  else ""
238
236
  )
239
- if model_billing and _srv()._user_can_use_llm(user, workspace_id, model_billing):
240
- billing = model_billing
237
+ connected = _srv()._user_llm_providers_for_picker(user)
238
+ prefer = str(prefer_provider or "").strip().lower()
239
+ if configured_billing and current_model:
240
+ billing = configured_billing
241
241
  elif prefer and _srv()._user_can_use_llm(user, workspace_id, prefer):
242
242
  billing = prefer
243
243
  else:
244
- billing = _srv()._llm_billing_provider(prof, user_id=user, workspace_id=workspace_id)
245
- if not _srv()._user_can_use_llm(user, workspace_id, billing):
246
- connected_sorted = sorted(connected)
247
- if not connected_sorted:
248
- return False
249
- billing = connected_sorted[0]
250
- if _srv()._profile_routing_matches_billing(prof, billing):
251
- return False
252
- if _srv()._oauth_llm_provider_spec(billing) and _srv()._user_can_use_llm(user, workspace_id, billing):
253
- current = str(_srv()._read_model_from_config(prof)[1] or "").strip()
254
- if not _srv()._resolve_billing_provider_for_model(current, {billing}, prefer=billing):
255
- current = str((_srv().PROVIDER_MVP_MODELS.get(billing) or {}).get("primary") or "")
256
- _srv()._apply_model_for_billing_provider(prof, billing, current, user_id=user)
257
- if _srv()._is_runtime_profile_slug(prof):
244
+ connected_sorted = sorted(connected)
245
+ if not connected_sorted:
246
+ return False
247
+ billing = connected_sorted[0]
248
+
249
+ if not current_model:
250
+ current_model = str(
251
+ (_srv().PROVIDER_MVP_MODELS.get(billing) or {}).get("primary") or ""
252
+ ).strip()
253
+ if not current_model:
254
+ return False
255
+
256
+ if _srv()._oauth_llm_provider_spec(billing):
257
+ if _srv()._profile_routing_matches_billing(prof, billing, block=block):
258
+ auth_changed = bool(
259
+ _srv()._user_can_use_llm(user, workspace_id, billing)
260
+ and _srv()._sync_oauth_llm_to_profile(prof, user, billing)
261
+ )
262
+ if auth_changed:
263
+ _srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
264
+ return auth_changed
265
+ changed = _srv()._apply_model_for_billing_provider(
266
+ prof,
267
+ billing,
268
+ current_model,
269
+ user_id=user if _srv()._user_can_use_llm(user, workspace_id, billing) else "",
270
+ )
271
+ if changed:
258
272
  _srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
259
- return True
260
- if _srv()._user_can_use_llm(user, workspace_id, billing) and _srv()._profile_llm_proxy_matches_billing(prof, billing):
273
+ return changed
274
+
275
+ if _srv()._profile_routing_matches_billing(prof, billing, block=block):
276
+ if not _srv()._profile_llm_proxy_ready(prof, billing):
277
+ before = _srv()._parse_model_block_from_disk(prof)
278
+ _srv()._ensure_profile_llm_proxy(prof, billing)
279
+ changed = before != _srv()._parse_model_block_from_disk(prof)
280
+ if changed:
281
+ _srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
282
+ return changed
261
283
  return False
262
- changed = _srv()._apply_mvp_model_for_provider(prof, billing)
263
- block = _srv()._read_model_block(prof)
264
- if _srv()._is_runtime_profile_slug(prof) or str(block.get("provider") or "").strip().lower() == "custom":
284
+
285
+ # API-key providers are vault-backed. Repair transport to the internal
286
+ # proxy while preserving the exact agent-selected model id.
287
+ changed = _srv()._apply_model_for_billing_provider(
288
+ prof,
289
+ billing,
290
+ current_model,
291
+ user_id=user,
292
+ )
293
+ if changed:
265
294
  _srv()._ensure_profile_llm_proxy(prof, billing)
266
- if changed and _srv()._is_runtime_profile_slug(prof):
267
295
  _srv()._reload_runtime_profile_gateway(prof, wait_healthy=True)
268
296
  return changed
269
297
 
@@ -340,6 +368,14 @@ def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id:
340
368
 
341
369
  provider, model_name = _srv()._read_model_from_config(prof)
342
370
  llm_provider = _srv()._llm_billing_provider(prof, provider or "")
371
+ # ChatGPT-account Codex rejects the historical gpt-5.4-medium default.
372
+ # Migrate generated profiles during ordinary bootstrap so existing installs
373
+ # converge without requiring users to delete their agent/session data.
374
+ if llm_provider == "codex" and str(model_name or "").strip() == "gpt-5.4-medium":
375
+ ok_model, _ = _srv()._set_profile_model(prof, "gpt-5.4-mini")
376
+ changed = ok_model or changed
377
+ model_name = "gpt-5.4-mini"
378
+
343
379
  if not str(model_name or "").strip():
344
380
  changed = _srv()._apply_mvp_model_for_provider(prof, llm_provider) or changed
345
381
  if _srv()._is_runtime_profile_slug(prof):